Implemented selecting nodes and running immediate actions

This commit is contained in:
thepaperpilot 2021-08-22 01:50:03 -05:00
parent 35b3226995
commit 11df9853d0
12 changed files with 346 additions and 103 deletions

View file

@ -6,6 +6,7 @@
ref="stage"
@init="onInit"
@mousemove="drag"
@mousedown="deselect"
@mouseup="() => endDragging(dragging)"
@mouseleave="() => endDragging(dragging)"
>
@ -18,18 +19,9 @@
:nodeType="board.types[node.type]"
:dragging="draggingNode"
:dragged="dragged"
:hasDragged="hasDragged"
:receivingNode="receivingNode?.id === node.id"
@startDragging="startDragging"
@endDragging="endDragging"
/>
<BoardNode
v-if="draggingNode"
:node="draggingNode"
:nodeType="board.types[draggingNode.type]"
:dragging="draggingNode"
:dragged="dragged"
:receivingNode="receivingNode?.id === draggingNode.id"
@startDragging="startDragging"
@mouseDown="mouseDown"
@endDragging="endDragging"
/>
</g>
@ -51,11 +43,13 @@ export default defineComponent({
return {
lastMousePosition: { x: 0, y: 0 },
dragged: { x: 0, y: 0 },
dragging: null
dragging: null,
hasDragged: false
} as {
lastMousePosition: { x: number; y: number };
dragged: { x: number; y: number };
dragging: string | null;
hasDragged: boolean;
};
},
props: {
@ -79,14 +73,15 @@ export default defineComponent({
];
},
draggingNode() {
return this.dragging
? player.layers[this.layer].boards[this.id].find(node => node.id === this.dragging)
: null;
return this.dragging ? this.board.nodes.find(node => node.id === this.dragging) : null;
},
nodes() {
return player.layers[this.layer].boards[this.id].filter(
node => node !== this.draggingNode
);
const nodes = this.board.nodes.slice();
if (this.draggingNode) {
const draggingNode = nodes.splice(nodes.indexOf(this.draggingNode), 1)[0];
nodes.push(draggingNode);
}
return nodes;
},
receivingNode(): BoardNode | null {
if (this.draggingNode == null) {
@ -99,6 +94,9 @@ export default defineComponent({
};
let smallestDistance = Number.MAX_VALUE;
return this.nodes.reduce((smallest: BoardNode | null, curr: BoardNode) => {
if (curr.id === this.draggingNode!.id) {
return smallest;
}
const nodeType = this.board.types[curr.type];
const canAccept =
typeof nodeType.canAccept === "boolean"
@ -126,10 +124,14 @@ export default defineComponent({
getZoomLevel(): number {
return (this.$refs.stage as any).$panZoomInstance.getTransform().scale;
},
onInit: function(panzoomInstance: any) {
onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null);
},
startDragging(e: MouseEvent, nodeID: string) {
deselect() {
player.layers[this.layer].boards[this.id].selectedNode = null;
player.layers[this.layer].boards[this.id].selectedAction = null;
},
mouseDown(e: MouseEvent, nodeID: string, draggable: boolean) {
if (this.dragging == null) {
e.preventDefault();
e.stopPropagation();
@ -139,33 +141,43 @@ export default defineComponent({
y: e.clientY
};
this.dragged = { x: 0, y: 0 };
this.hasDragged = false;
this.dragging = nodeID;
if (draggable) {
this.dragging = nodeID;
}
}
player.layers[this.layer].boards[this.id].selectedNode = null;
player.layers[this.layer].boards[this.id].selectedAction = null;
},
drag(e: MouseEvent) {
const zoom = (this.getZoomLevel as () => number)();
this.dragged = {
x: this.dragged.x + (e.clientX - this.lastMousePosition.x) / zoom,
y: this.dragged.y + (e.clientY - this.lastMousePosition.y) / zoom
};
this.lastMousePosition = {
x: e.clientX,
y: e.clientY
};
if (Math.abs(this.dragged.x) > 10 || Math.abs(this.dragged.y) > 10) {
this.hasDragged = true;
}
if (this.dragging) {
e.preventDefault();
e.stopPropagation();
const zoom = (this.getZoomLevel as () => number)();
this.dragged = {
x: this.dragged.x + (e.clientX - this.lastMousePosition.x) / zoom,
y: this.dragged.y + (e.clientY - this.lastMousePosition.y) / zoom
}
this.lastMousePosition = {
x: e.clientX,
y: e.clientY
};
}
},
endDragging(nodeID: string | null) {
if (this.dragging != null && this.dragging === nodeID) {
const nodes = player.layers[this.layer].boards[this.id];
const draggingNode = this.draggingNode!;
const receivingNode = this.receivingNode;
draggingNode.position.x += Math.round(this.dragged.x / 25) * 25;
draggingNode.position.y += Math.round(this.dragged.y / 25) * 25;
const nodes = this.board.nodes;
nodes.splice(nodes.indexOf(draggingNode), 1);
nodes.push(draggingNode);

View file

@ -1,13 +1,38 @@
<template>
<g
class="boardnode"
:style="{ opacity: dragging?.id === node.id ? 0.5 : 1 }"
:style="{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }"
:transform="`translate(${position.x},${position.y})`"
@mouseenter="mouseEnter"
@mouseleave="mouseLeave"
@mousedown="mouseDown"
>
<g v-if="shape === Shape.Circle">
<transition name="actions" appear>
<g v-if="selected && actions">
<g
v-for="(action, index) in actions"
:key="action.id"
class="action"
:transform="
`translate(
${(-size - 30) *
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)},
${(size + 30) *
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)}
)`
"
@click="performAction(action)"
>
<circle :fill="fillColor" r="20" />
<text :fill="titleColor" class="material-icons">{{ action.icon }}</text>
</g>
</g>
</transition>
<g
v-if="shape === Shape.Circle"
@mouseenter="mouseEnter"
@mouseleave="mouseLeave"
@mousedown="mouseDown"
@mouseup="mouseUp"
>
<circle
v-if="canAccept"
:r="size + 8"
@ -36,21 +61,30 @@
:stroke="progressColor"
/>
</g>
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
<g
v-else-if="shape === Shape.Diamond"
transform="rotate(45, 0, 0)"
@mouseenter="mouseEnter"
@mouseleave="mouseLeave"
@mousedown="mouseDown"
@mouseup="mouseUp"
>
<rect
v-if="canAccept"
:width="size + 16"
:height="size + 16"
:transform="`translate(${-(size + 16) / 2}, ${-(size + 16) / 2})`"
:width="size * sqrtTwo + 16"
:height="size * sqrtTwo + 16"
:transform="
`translate(${-(size * sqrtTwo + 16) / 2}, ${-(size * sqrtTwo + 16) / 2})`
"
:fill="backgroundColor"
:stroke="receivingNode ? '#0F0' : '#0F03'"
:stroke-width="2"
/>
<rect
:width="size"
:height="size"
:transform="`translate(${-size / 2}, ${-size / 2})`"
:width="size * sqrtTwo"
:height="size * sqrtTwo"
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
:fill="fillColor"
:stroke="outlineColor"
:stroke-width="4"
@ -58,11 +92,11 @@
<rect
v-if="progressDisplay === ProgressDisplay.Fill"
:width="Math.max(size * progress - 2, 0)"
:height="Math.max(size * progress - 2, 0)"
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
:transform="
`translate(${-Math.max(size * progress - 2, 0) / 2}, ${-Math.max(
size * progress - 2,
`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${-Math.max(
size * sqrtTwo * progress - 2,
0
) / 2})`
"
@ -70,13 +104,13 @@
/>
<rect
v-else
:width="size + 9"
:height="size + 9"
:transform="`translate(${-(size + 9) / 2}, ${-(size + 9) / 2})`"
:width="size * sqrtTwo + 9"
:height="size * sqrtTwo + 9"
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${-(size * sqrtTwo + 9) / 2})`"
fill="transparent"
:stroke-dasharray="(size + 9) * 4"
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
:stroke-width="5"
:stroke-dashoffset="(size + 9) * 4 - progress * (size + 9) * 4"
:stroke-dashoffset="(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4"
:stroke="progressColor"
/>
</g>
@ -88,8 +122,9 @@
<script lang="ts">
import themes from "@/data/themes";
import { ProgressDisplay, Shape } from "@/game/enums";
import { layers } from "@/game/layers";
import player from "@/game/player";
import { BoardNode, NodeType } from "@/typings/features/board";
import { BoardNode, BoardNodeAction, NodeType } from "@/typings/features/board";
import { getNodeTypeProperty } from "@/util/features";
import { InjectLayerMixin } from "@/util/vue";
import { defineComponent, PropType } from "vue";
@ -102,10 +137,11 @@ export default defineComponent({
ProgressDisplay,
Shape,
lastMousePosition: { x: 0, y: 0 },
hovering: false
hovering: false,
sqrtTwo: Math.sqrt(2)
};
},
emits: ["startDragging", "endDragging"],
emits: ["mouseDown", "endDragging"],
props: {
node: {
type: Object as PropType<BoardNode>,
@ -122,12 +158,25 @@ export default defineComponent({
type: Object as PropType<{ x: number; y: number }>,
required: true
},
hasDragged: {
type: Boolean,
default: false
},
receivingNode: {
type: Boolean,
default: false
}
},
computed: {
board() {
return layers[this.nodeType.layer].boards!.data[this.nodeType.id];
},
selected() {
return this.board.selectedNode?.id === this.node.id;
},
actions(): BoardNodeAction[] | null | undefined {
return getNodeTypeProperty(this.nodeType, this.node, "actions");
},
draggable(): boolean {
return getNodeTypeProperty(this.nodeType, this.node, "draggable");
},
@ -146,7 +195,7 @@ export default defineComponent({
let size: number = getNodeTypeProperty(this.nodeType, this.node, "size");
if (this.receivingNode) {
size *= 1.25;
} else if (this.hovering) {
} else if (this.hovering || this.selected) {
size *= 1.15;
}
return size;
@ -188,18 +237,24 @@ export default defineComponent({
);
},
canAccept(): boolean {
if (this.dragging == null) {
if (this.dragging == null || !this.hasDragged) {
return false;
}
return typeof this.nodeType.canAccept === "boolean"
? this.nodeType.canAccept
: this.nodeType.canAccept(this.node, this.dragging);
},
actionDistance(): number {
return getNodeTypeProperty(this.nodeType, this.node, "actionDistance");
}
},
methods: {
mouseDown(e: MouseEvent) {
if (this.draggable) {
this.$emit('startDragging', e, this.node.id);
this.$emit("mouseDown", e, this.node.id, this.draggable);
},
mouseUp() {
if (!this.hasDragged) {
this.nodeType.onClick?.(this.node);
}
},
mouseEnter() {
@ -207,11 +262,14 @@ export default defineComponent({
},
mouseLeave() {
this.hovering = false;
},
performAction(action: BoardNodeAction) {
action.onClick(this.node);
}
},
watch: {
onDraggableChanged() {
if (this.dragging && !this.draggable) {
if (this.dragging?.id === this.node.id && !this.draggable) {
this.$emit("endDragging", this.node.id);
}
}
@ -230,9 +288,30 @@ export default defineComponent({
dominant-baseline: middle;
font-family: monospace;
font-size: 200%;
pointer-events: none;
}
.progressRing {
transform: rotate(-90deg);
}
.action:hover circle {
r: 25;
}
.action:hover text {
font-size: 187.5%; /* 150% * 1.25 */
}
.action text {
text-anchor: middle;
dominant-baseline: central;
}
</style>
<style>
.actions-enter-from .action,
.actions-leave-to .action {
transform: translate(0, 0);
}
</style>

80
src/data/layers/Main.vue Normal file
View file

@ -0,0 +1,80 @@
<template>
<div v-if="devSpeed === 0">Game Paused</div>
<div v-else-if="devSpeed && devSpeed !== 1">Dev Speed: {{ formattedDevSpeed }}x</div>
<Board id="main" />
<Modal :show="showModal" @close="closeModal">
<template v-slot:header v-if="title">
<component :is="title" />
</template>
<template v-slot:body v-if="body">
<component :is="body" />
</template>
<template v-slot:footer v-if="footer">
<component :is="footer" />
</template>
</Modal>
</template>
<script lang="ts">
import player from "@/game/player";
import { CoercableComponent } from "@/typings/component";
import { format } from "@/util/break_eternity";
import { camelToTitle } from "@/util/common";
import { coerceComponent } from "@/util/vue";
import { computed, defineComponent, shallowRef, watchEffect } from "vue";
import { ActionNodeData, ResourceNodeData } from "./main";
export default defineComponent(function Main() {
const title = shallowRef<CoercableComponent | null>(null);
const body = shallowRef<CoercableComponent | null>(null);
const footer = shallowRef<CoercableComponent | null>(null);
watchEffect(() => {
const node = player.layers.main.boards.main.nodes.find(
node => node.id === player.layers.main.openNode
);
if (node == null) {
player.layers.main.showModal = false;
return;
}
switch (node.type) {
default:
player.layers.main.showModal = false;
break;
case "resource":
switch ((node.data as ResourceNodeData).resourceType) {
default:
player.layers.main.showModal = false;
break;
case "time":
title.value = coerceComponent("<h2>Time</h2>");
body.value = coerceComponent(
"The ultimate resource, that you'll never have enough of."
);
break;
}
break;
case "action":
title.value = coerceComponent(
camelToTitle((node.data as ActionNodeData).actionType)
);
body.value = coerceComponent(
"<div><div>" +
(node.data as ActionNodeData).log.join("</div><div>") +
"</div></div>"
);
break;
}
});
const showModal = computed(() => player.layers.main.showModal);
const closeModal = () => {
player.layers.main.showModal = false;
};
const devSpeed = computed(() => player.devSpeed);
const formattedDevSpeed = computed(() => player.devSpeed && format(player.devSpeed));
return { title, body, footer, showModal, closeModal, devSpeed, formattedDevSpeed };
});
</script>

View file

@ -4,36 +4,34 @@ import Decimal, { DecimalSource } from "@/lib/break_eternity";
import { RawLayer } from "@/typings/layer";
import { camelToTitle } from "@/util/common";
import themes from "../themes";
import Main from "./Main.vue";
type ResourceNodeData = {
export type ResourceNodeData = {
resourceType: string;
amount: DecimalSource;
maxAmount: DecimalSource;
};
type ItemNodeData = {
export type ItemNodeData = {
itemType: string;
amount: DecimalSource;
};
type ActionNodeData = {
export type ActionNodeData = {
actionType: string;
log: string[];
};
export default {
id: "main",
display: `
<div v-if="player.devSpeed === 0">Game Paused</div>
<div v-else-if="player.devSpeed && player.devSpeed !== 1">Dev Speed: {{ format(player.devSpeed) }}x</div>
<div>TODO: Board</div>
<Board id="main" />
`,
display: Main,
startData() {
return {
openNode: null
openNode: null,
showModal: false
} as {
openNode: string | null;
showModal: boolean;
};
},
minimizable: false,
@ -71,7 +69,8 @@ export default {
position: { x: -150, y: 150 },
type: "action",
data: {
actionType: "browse"
actionType: "web",
log: []
}
}
];
@ -101,31 +100,55 @@ export default {
canAccept(node, otherNode) {
return otherNode.type === "item";
},
onClick(node) {
player.layers.main.openNode = node.id;
player.layers.main.showModal = true;
},
onDrop(node, otherNode) {
const index = player.layers[this.layer].boards[this.id].indexOf(
const index = player.layers[this.layer].boards[this.id].nodes.indexOf(
otherNode
);
player.layers[this.layer].boards[this.id].splice(index, 1);
player.layers[this.layer].boards[this.id].nodes.splice(index, 1);
}
},
item: {
title(node) {
return (node.data as ItemNodeData).itemType;
},
onClick(node) {
player.layers.main.openNode = node.id;
player.layers.main.showModal = true;
},
draggable: true
},
action: {
title(node) {
return camelToTitle((node.data as ActionNodeData).actionType);
},
fillColor() {
return themes[player.theme].variables["--background-tooltip"];
},
fillColor: "#000",
draggable: true,
shape: Shape.Diamond,
size: 100,
progressColor: "#0FF3",
progressDisplay: ProgressDisplay.Outline
progressDisplay: ProgressDisplay.Outline,
actions: [
{
id: "info",
icon: "history_edu",
tooltip: "Log",
onClick(node) {
player.layers.main.openNode = node.id;
player.layers.main.showModal = true;
}
},
{
id: "reddit",
icon: "reddit",
tooltip: "Browse Reddit",
onClick(node) {
// TODO
}
}
]
}
}
}

View file

@ -68,10 +68,10 @@ function updateLayers(diff: DecimalSource) {
);
}
layers[layer].update?.(diff);
if (layers[layer].boards) {
Reflect.ownKeys(player.layers[layer].boards).forEach(board => {
player.layers[layer].boards[board.toString()].forEach(node => {
const nodeType = layers[layer].boards!.data[board.toString()].types[node.type];
if (layers[layer].boards && layers[layer].boards?.data) {
Object.values(layers[layer].boards!.data!).forEach(board => {
board.nodes.forEach(node => {
const nodeType = board.types[node.type];
nodeType.update?.(node, diff);
});
});

View file

@ -432,6 +432,29 @@ export function addLayer(layer: RawLayer, player?: Partial<PlayerData>): void {
for (const id in layer.boards.data) {
setDefault(layer.boards.data[id], "width", "100%");
setDefault(layer.boards.data[id], "height", "400px");
setDefault(layer.boards.data[id], "nodes", function() {
return playerProxy.layers[this.layer].boards[this.id].nodes;
});
setDefault(layer.boards.data[id], "selectedNode", function() {
return playerProxy.layers[this.layer].boards[this.id].nodes.find(
node => node.id === playerProxy.layers[this.layer].boards[this.id].selectedNode
);
});
setDefault(layer.boards.data[id], "selectedAction", function() {
if (this.selectedNode == null) {
return null;
}
const nodeType = layers[this.layer].boards!.data[this.id].types[
this.selectedNode.type
];
if (nodeType.actions === null) {
return null;
}
if (typeof nodeType.actions === "function") {
return nodeType.actions(this.selectedNode);
}
return nodeType.actions;
});
for (const nodeType in layer.boards.data[id].types) {
layer.boards.data[id].types[nodeType].layer = layer.id;
layer.boards.data[id].types[nodeType].id = id;
@ -440,16 +463,20 @@ export function addLayer(layer: RawLayer, player?: Partial<PlayerData>): void {
setDefault(layer.boards.data[id].types[nodeType], "draggable", false);
setDefault(layer.boards.data[id].types[nodeType], "shape", Shape.Circle);
setDefault(layer.boards.data[id].types[nodeType], "canAccept", false);
setDefault(layer.boards.data[id].types[nodeType], "actionDistance", Math.PI / 6);
setDefault(
layer.boards.data[id].types[nodeType],
"progressDisplay",
ProgressDisplay.Fill
);
setDefault(layer.boards.data[id].types[nodeType], "nodes", function() {
return playerProxy.layers[this.layer].boards[this.id].filter(
return playerProxy.layers[this.layer].boards[this.id].nodes.filter(
node => node.type === this.type
);
});
setDefault(layer.boards.data[id].types[nodeType], "onClick", function(node) {
playerProxy.layers[this.layer].boards[this.id].selectedNode = node.id;
});
}
}
}

View file

@ -1,3 +1,3 @@
import { ComponentOptions } from "vue";
import { Component, ComponentOptions } from "vue";
export type CoercableComponent = string | ComponentOptions;
export type CoercableComponent = string | ComponentOptions | Component;

View file

@ -13,9 +13,10 @@ export interface BoardNode {
data?: State;
}
export interface CardOption {
text: string;
selected: (node: BoardNode) => void;
export interface BoardData {
nodes: BoardNode[];
selectedNode: string | null;
selectedAction: string | null;
}
export interface Board extends Feature {
@ -24,6 +25,9 @@ export interface Board extends Feature {
height: string;
width: string;
types: Record<string, NodeType>;
nodes: BoardNode[];
selectedNode: BoardNode | null;
selectedAction: BoardNodeAction | null;
}
export type RawBoard = Omit<RawFeature<Board>, "types" | "startNodes"> & {
@ -43,8 +47,17 @@ export interface NodeType extends Feature {
fillColor?: string | ((node: BoardNode) => string);
outlineColor?: string | ((node: BoardNode) => string);
titleColor?: string | ((node: BoardNode) => string);
actions?: BoardNodeAction[] | ((node: BoardNode) => BoardNodeAction[]);
actionDistance: number | ((node: BoardNode) => number);
onClick?: (node: BoardNode) => void;
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
update?: (node: BoardNode, diff: DecimalSource) => void;
nodes: BoardNode[];
}
export interface BoardNodeAction {
id: string;
icon: string;
tooltip: string;
onClick: (node: BoardNode) => void;
}

View file

@ -1,7 +1,7 @@
import { Themes } from "@/data/themes";
import { DecimalSource } from "@/lib/break_eternity";
import Decimal from "@/util/bignum";
import { BoardNode } from "./features/board";
import { BoardData, BoardNode } from "./features/board";
import { MilestoneDisplay } from "./features/milestone";
import { State } from "./state";
@ -62,7 +62,7 @@ export interface LayerSaveData {
clickables: Record<string, State>;
challenges: Record<string, Decimal>;
grids: Record<string, Record<string, State>>;
boards: Record<string, BoardNode[]>;
boards: Record<string, BoardData>;
confirmRespecBuyables: boolean;
[index: string]: unknown;
}

View file

@ -1,7 +1,7 @@
import { hotkeys, layers } from "@/game/layers";
import player from "@/game/player";
import { CacheableFunction } from "@/typings/cacheableFunction";
import { Board, BoardNode, RawBoard } from "@/typings/features/board";
import { Board, BoardData, BoardNode, RawBoard } from "@/typings/features/board";
import { Buyable } from "@/typings/features/buyable";
import { Challenge } from "@/typings/features/challenge";
import { Clickable } from "@/typings/features/clickable";
@ -73,17 +73,21 @@ export function getStartingChallenges(
export function getStartingBoards(
boards?: Record<string, Board> | Record<string, RawBoard> | undefined
): Record<string, BoardNode[]> {
): Record<string, BoardData> {
return boards
? Object.keys(boards).reduce((acc: Record<string, BoardNode[]>, curr: string): Record<
? Object.keys(boards).reduce((acc: Record<string, BoardData>, curr: string): Record<
string,
BoardNode[]
BoardData
> => {
const nodes = boards[curr].startNodes?.() || [];
acc[curr] = nodes.map((node, index) => ({
id: index.toString(),
...node
})) as BoardNode[];
acc[curr] = {
nodes: nodes.map((node, index) => ({
id: index.toString(),
...node
})),
selectedNode: null,
selectedAction: null
} as BoardData;
return acc;
}, {})
: {};

View file

@ -43,7 +43,8 @@ function travel(
object[key] = computed(object[key].bind(objectProxy));
} else if (
(isPlainObject(object[key]) || Array.isArray(object[key])) &&
!(object[key] instanceof Decimal)
!(object[key] instanceof Decimal) &&
typeof object[key].render !== "function"
) {
object[key] = callback(object[key]);
}
@ -62,7 +63,11 @@ const layerHandler: ProxyHandler<Record<string, any>> = {
if (isRef(target[key])) {
return target[key].value;
} else if (target[key].isProxy || target[key] instanceof Decimal) {
} else if (
target[key].isProxy ||
target[key] instanceof Decimal ||
typeof target[key].render === "function"
) {
return target[key];
} else if (
(isPlainObject(target[key]) || Array.isArray(target[key])) &&

View file

@ -35,7 +35,7 @@ const data = function(): Record<string, unknown> {
return { Decimal, player, layers, hasWon, pointGain, ...numberUtils };
};
export function coerceComponent(
component: string | ComponentOptions,
component: string | ComponentOptions | Component,
defaultWrapper = "span"
): Component | string {
if (typeof component === "string") {