Implemented dropping nodes into each other

Also improved node z-ordering and other related things
This commit is contained in:
thepaperpilot 2021-08-20 00:47:56 -05:00
parent f3b934337f
commit 02443bbb0c
8 changed files with 249 additions and 100 deletions

View file

@ -2,18 +2,35 @@
<panZoom <panZoom
:style="style" :style="style"
selector="#g1" selector="#g1"
@init="onInit"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10 }" :options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10 }"
ref="stage" ref="stage"
@init="onInit"
@mousemove="drag"
@mouseup="() => endDragging(dragging)"
@mouseleave="() => endDragging(dragging)"
> >
<svg class="stage" width="100%" height="100%"> <svg class="stage" width="100%" height="100%">
<g id="g1"> <g id="g1">
<BoardNode <BoardNode
v-for="(node, nodeIndex) in nodes" v-for="node in nodes"
:key="nodeIndex" :key="node.id"
:index="nodeIndex"
:node="node" :node="node"
:nodeType="board.types[node.type]" :nodeType="board.types[node.type]"
:dragging="draggingNode"
:dragged="dragged"
: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"
@endDragging="endDragging"
/> />
</g> </g>
</svg> </svg>
@ -23,24 +40,30 @@
<script lang="ts"> <script lang="ts">
import { layers } from "@/game/layers"; import { layers } from "@/game/layers";
import player from "@/game/player"; import player from "@/game/player";
import { Board } from "@/typings/features/board"; import { Board, BoardNode } from "@/typings/features/board";
import { InjectLayerMixin } from "@/util/vue"; import { InjectLayerMixin } from "@/util/vue";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
export default defineComponent({ export default defineComponent({
name: "Board", name: "Board",
mixins: [InjectLayerMixin], mixins: [InjectLayerMixin],
data() {
return {
lastMousePosition: { x: 0, y: 0 },
dragged: { x: 0, y: 0 },
dragging: null
} as {
lastMousePosition: { x: number; y: number };
dragged: { x: number; y: number };
dragging: string | null;
};
},
props: { props: {
id: { id: {
type: [Number, String], type: [Number, String],
required: true required: true
} }
}, },
provide() {
return {
getZoomLevel: () => (this.$refs.stage as any).$panZoomInstance.getTransform().scale
};
},
computed: { computed: {
board(): Board { board(): Board {
return layers[this.layer].boards!.data[this.id]; return layers[this.layer].boards!.data[this.id];
@ -55,13 +78,101 @@ export default defineComponent({
this.board.style this.board.style
]; ];
}, },
draggingNode() {
return this.dragging
? player.layers[this.layer].boards[this.id].find(node => node.id === this.dragging)
: null;
},
nodes() { nodes() {
return player.layers[this.layer].boards[this.id]; return player.layers[this.layer].boards[this.id].filter(
node => node !== this.draggingNode
);
},
receivingNode(): BoardNode | null {
if (this.draggingNode == null) {
return null;
}
const position = {
x: this.draggingNode.position.x + this.dragged.x,
y: this.draggingNode.position.y + this.dragged.y
};
let smallestDistance = Number.MAX_VALUE;
return this.nodes.reduce((smallest: BoardNode | null, curr: BoardNode) => {
const nodeType = this.board.types[curr.type];
const canAccept =
typeof nodeType.canAccept === "boolean"
? nodeType.canAccept
: nodeType.canAccept(curr, this.draggingNode!);
if (!canAccept) {
return smallest;
}
const distanceSquared =
Math.pow(position.x - curr.position.x, 2) +
Math.pow(position.y - curr.position.y, 2);
const size =
typeof nodeType.size === "number" ? nodeType.size : nodeType.size(curr);
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
return smallest;
}
smallestDistance = distanceSquared;
return curr;
}, null);
} }
}, },
methods: { methods: {
getZoomLevel(): number {
return (this.$refs.stage as any).$panZoomInstance.getTransform().scale;
},
onInit: function(panzoomInstance) { onInit: function(panzoomInstance) {
panzoomInstance.setTransformOrigin(null); panzoomInstance.setTransformOrigin(null);
},
startDragging(e: MouseEvent, nodeID: string) {
if (this.dragging == null) {
e.preventDefault();
e.stopPropagation();
this.lastMousePosition = {
x: e.clientX,
y: e.clientY
};
this.dragged = { x: 0, y: 0 };
this.dragging = nodeID;
}
},
drag(e: MouseEvent) {
if (this.dragging) {
e.preventDefault();
e.stopPropagation();
const zoom = (this.getZoomLevel as () => number)();
this.dragged.x += (e.clientX - this.lastMousePosition.x) / zoom;
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;
nodes.splice(nodes.indexOf(draggingNode), 1);
nodes.push(draggingNode);
if (receivingNode) {
this.board.types[receivingNode.type].onDrop(receivingNode, draggingNode);
}
this.dragging = null;
}
} }
} }
}); });

View file

@ -1,11 +1,19 @@
<template> <template>
<g <g
class="boardnode" class="boardnode"
:style="{ opacity: dragging ? 0.5 : 1 }" :style="{ opacity: dragging?.id === node.id ? 0.5 : 1 }"
:transform="`translate(${position.x},${position.y})`" :transform="`translate(${position.x},${position.y})`"
@mousedown="mouseDown" @mouseenter="mouseEnter"
@mouseleave="mouseLeave"
@mousedown="e => $emit('startDragging', e, node.id)"
> >
<circle :r="size + 8" :fill="backgroundColor" stroke="#0F03" :stroke-width="2" /> <circle
v-if="canAccept"
:r="size + 8"
:fill="backgroundColor"
:stroke="receivingNode ? '#0F0' : '#0F03'"
:stroke-width="2"
/>
<circle :r="size" :fill="fillColor" :stroke="outlineColor" :stroke-width="4" /> <circle :r="size" :fill="fillColor" :stroke="outlineColor" :stroke-width="4" />
@ -34,45 +42,22 @@ import themes from "@/data/themes";
import { ProgressDisplay } from "@/game/enums"; import { ProgressDisplay } from "@/game/enums";
import player from "@/game/player"; import player from "@/game/player";
import { BoardNode, NodeType } from "@/typings/features/board"; import { BoardNode, NodeType } from "@/typings/features/board";
import { getNodeTypeProperty } from "@/util/features";
import { InjectLayerMixin } from "@/util/vue"; import { InjectLayerMixin } from "@/util/vue";
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
// TODO will blindly use any T given (can't restrict it to S[R] because I can't figure out how
// to make it support narrowing the return type)
function getTypeProperty<T, S extends NodeType, R extends keyof S>(
nodeType: S,
node: BoardNode,
property: R
): S[R] extends Pick<
S,
{
[K in keyof S]-?: undefined extends S[K] ? never : K;
}[keyof S]
>
? T
: T | undefined {
return typeof nodeType[property] === "function"
? (nodeType[property] as (node: BoardNode) => T)(node)
: (nodeType[property] as T);
}
export default defineComponent({ export default defineComponent({
name: "BoardNode", name: "BoardNode",
mixins: [InjectLayerMixin], mixins: [InjectLayerMixin],
inject: ["getZoomLevel"],
data() { data() {
return { return {
ProgressDisplay, ProgressDisplay,
lastMousePosition: { x: 0, y: 0 }, lastMousePosition: { x: 0, y: 0 },
dragged: { x: 0, y: 0 }, hovering: false
dragging: false
}; };
}, },
emits: ["startDragging", "endDragging"],
props: { props: {
index: {
type: Number,
required: true
},
node: { node: {
type: Object as PropType<BoardNode>, type: Object as PropType<BoardNode>,
required: true required: true
@ -80,14 +65,25 @@ export default defineComponent({
nodeType: { nodeType: {
type: Object as PropType<NodeType>, type: Object as PropType<NodeType>,
required: true required: true
},
dragging: {
type: Object as PropType<BoardNode>
},
dragged: {
type: Object as PropType<{ x: number; y: number }>,
required: true
},
receivingNode: {
type: Boolean,
default: false
} }
}, },
computed: { computed: {
draggable(): boolean { draggable(): boolean {
return getTypeProperty(this.nodeType, this.node, "draggable"); return getNodeTypeProperty(this.nodeType, this.node, "draggable");
}, },
position(): { x: number; y: number } { position(): { x: number; y: number } {
return this.draggable && this.dragging return this.draggable && this.dragging?.id === this.node.id
? { ? {
x: this.node.position.x + Math.round(this.dragged.x / 25) * 25, x: this.node.position.x + Math.round(this.dragged.x / 25) * 25,
y: this.node.position.y + Math.round(this.dragged.y / 25) * 25 y: this.node.position.y + Math.round(this.dragged.y / 25) * 25
@ -95,89 +91,71 @@ export default defineComponent({
: this.node.position; : this.node.position;
}, },
size(): number { size(): number {
return getTypeProperty(this.nodeType, this.node, "size"); let size: number = getNodeTypeProperty(this.nodeType, this.node, "size");
if (this.receivingNode) {
size *= 1.25;
} else if (this.hovering) {
size *= 1.15;
}
return size;
}, },
title(): string { title(): string {
return getTypeProperty(this.nodeType, this.node, "title"); return getNodeTypeProperty(this.nodeType, this.node, "title");
}, },
progress(): number { progress(): number {
return getTypeProperty(this.nodeType, this.node, "progress") || 0; return getNodeTypeProperty(this.nodeType, this.node, "progress") || 0;
}, },
backgroundColor(): string { backgroundColor(): string {
return themes[player.theme].variables["--background"]; return themes[player.theme].variables["--background"];
}, },
outlineColor(): string { outlineColor(): string {
return ( return (
getTypeProperty(this.nodeType, this.node, "outlineColor") || getNodeTypeProperty(this.nodeType, this.node, "outlineColor") ||
themes[player.theme].variables["--separator"] themes[player.theme].variables["--separator"]
); );
}, },
fillColor(): string { fillColor(): string {
return ( return (
getTypeProperty(this.nodeType, this.node, "fillColor") || getNodeTypeProperty(this.nodeType, this.node, "fillColor") ||
themes[player.theme].variables["--secondary-background"] themes[player.theme].variables["--secondary-background"]
); );
}, },
progressColor(): string { progressColor(): string {
return getTypeProperty(this.nodeType, this.node, "progressColor") || "none"; return getNodeTypeProperty(this.nodeType, this.node, "progressColor") || "none";
}, },
titleColor(): string { titleColor(): string {
return ( return (
getTypeProperty(this.nodeType, this.node, "titleColor") || getNodeTypeProperty(this.nodeType, this.node, "titleColor") ||
themes[player.theme].variables["--color"] themes[player.theme].variables["--color"]
); );
}, },
progressDisplay(): ProgressDisplay { progressDisplay(): ProgressDisplay {
return ( return (
getTypeProperty(this.nodeType, this.node, "progressDisplay") || getNodeTypeProperty(this.nodeType, this.node, "progressDisplay") ||
ProgressDisplay.Outline ProgressDisplay.Outline
); );
},
canAccept(): boolean {
if (this.dragging == null) {
return false;
}
return typeof this.nodeType.canAccept === "boolean"
? this.nodeType.canAccept
: this.nodeType.canAccept(this.node, this.dragging);
} }
}, },
methods: { methods: {
mouseDown(e: MouseEvent) { mouseEnter() {
if (this.draggable) { this.hovering = true;
e.preventDefault(); },
e.stopPropagation(); mouseLeave() {
this.hovering = false;
this.lastMousePosition = {
x: e.clientX,
y: e.clientY
};
this.dragged = { x: 0, y: 0 };
this.dragging = true;
document.onmouseup = this.mouseUp;
document.onmousemove = this.mouseMove;
} }
}, },
mouseMove(e: MouseEvent) { watch: {
if (this.draggable && this.dragging) { onDraggableChanged() {
e.preventDefault(); if (this.dragging && !this.draggable) {
e.stopPropagation(); this.$emit("endDragging", this.node.id);
const zoom = (this.getZoomLevel as () => number)();
console.log(zoom);
this.dragged.x += (e.clientX - this.lastMousePosition.x) / zoom;
this.dragged.y += (e.clientY - this.lastMousePosition.y) / zoom;
this.lastMousePosition = {
x: e.clientX,
y: e.clientY
};
}
},
mouseUp(e: MouseEvent) {
if (this.draggable && this.dragging) {
e.preventDefault();
e.stopPropagation();
let node = player.layers[this.nodeType.layer].boards[this.nodeType.id][this.index];
node.position.x += Math.round(this.dragged.x / 25) * 25;
node.position.y += Math.round(this.dragged.y / 25) * 25;
this.dragging = false;
document.onmouseup = null;
document.onmousemove = null;
} }
} }
} }

View file

@ -9,6 +9,11 @@ type ResourceNodeData = {
maxAmount: DecimalSource; maxAmount: DecimalSource;
}; };
type ItemNodeData = {
itemType: string;
amount: DecimalSource;
};
export default { export default {
id: "main", id: "main",
display: ` display: `
@ -39,6 +44,14 @@ export default {
amount: new Decimal(24 * 60 * 60), amount: new Decimal(24 * 60 * 60),
maxAmount: new Decimal(24 * 60 * 60) maxAmount: new Decimal(24 * 60 * 60)
} }
},
{
position: { x: 0, y: 150 },
type: "item",
data: {
itemType: "speed",
amount: new Decimal(5 * 60 * 60)
}
} }
]; ];
}, },
@ -63,7 +76,22 @@ export default {
default: default:
return "none"; return "none";
} }
},
canAccept(node, otherNode) {
return otherNode.type === "item";
},
onDrop(node, otherNode) {
const index = player.layers[this.layer].boards[this.id].indexOf(
otherNode
);
player.layers[this.layer].boards[this.id].splice(index, 1);
} }
},
item: {
title(node) {
return (node.data as ItemNodeData).itemType;
},
draggable: true
} }
} }
} }

View file

@ -104,6 +104,12 @@ const playerHandler: ProxyHandler<Record<string, any>> = {
} }
} }
return true; return true;
},
ownKeys(target: Record<string, any>) {
return Reflect.ownKeys(target.__state);
},
has(target: Record<string, any>, key: string) {
return Reflect.has(target.__state, key);
} }
}; };
export default window.player = new Proxy( export default window.player = new Proxy(

View file

@ -2,6 +2,7 @@ import { State } from "../state";
import { Feature, RawFeature } from "./feature"; import { Feature, RawFeature } from "./feature";
export interface BoardNode { export interface BoardNode {
id: string;
position: { position: {
x: number; x: number;
y: number; y: number;
@ -16,15 +17,15 @@ export interface CardOption {
} }
export interface Board extends Feature { export interface Board extends Feature {
startNodes: () => BoardNode[]; startNodes: () => Omit<BoardNode, "id">[];
style?: Partial<CSSStyleDeclaration>; style?: Partial<CSSStyleDeclaration>;
height: string; height: string;
width: string; width: string;
types: Record<string, NodeType>; types: Record<string, NodeType>;
} }
export type RawBoard = Omit<RawFeature<Board>, "types"> & { export type RawBoard = Omit<RawFeature<Board>, "types" | "startNodes"> & {
startNodes: () => BoardNode[]; startNodes: () => Omit<BoardNode, "id">[];
types: Record<string, RawFeature<NodeType>>; types: Record<string, RawFeature<NodeType>>;
}; };
@ -41,5 +42,6 @@ export interface NodeType extends Feature {
outlineColor?: string | ((node: BoardNode) => string); outlineColor?: string | ((node: BoardNode) => string);
titleColor?: string | ((node: BoardNode) => string); titleColor?: string | ((node: BoardNode) => string);
onClick: (node: BoardNode) => void; onClick: (node: BoardNode) => void;
onDrop: (node: BoardNode, otherNode: BoardNode) => void;
nodes: BoardNode[]; nodes: BoardNode[];
} }

View file

@ -62,7 +62,7 @@ export interface LayerSaveData {
clickables: Record<string, State>; clickables: Record<string, State>;
challenges: Record<string, Decimal>; challenges: Record<string, Decimal>;
grids: Record<string, Record<string, State>>; grids: Record<string, Record<string, State>>;
boards: Record<string, Array<BoardNode>>; boards: Record<string, BoardNode[]>;
confirmRespecBuyables: boolean; confirmRespecBuyables: boolean;
[index: string]: unknown; [index: string]: unknown;
} }

View file

@ -1,4 +1,5 @@
import { layers } from "@/game/layers"; import { layers } from "@/game/layers";
import { NodeType, BoardNode } from "@/typings/features/board";
import { GridCell } from "@/typings/features/grid"; import { GridCell } from "@/typings/features/grid";
import { State } from "@/typings/state"; import { State } from "@/typings/state";
import Decimal, { DecimalSource } from "@/util/bignum"; import Decimal, { DecimalSource } from "@/util/bignum";
@ -87,3 +88,22 @@ export function achievementEffect(layer: string, id: string | number): State | u
export function gridEffect(layer: string, id: string, cell: string | number): State | undefined { export function gridEffect(layer: string, id: string, cell: string | number): State | undefined {
return (layers[layer].grids?.data[id][cell] as GridCell).effect; return (layers[layer].grids?.data[id][cell] as GridCell).effect;
} }
// TODO will blindly use any T given (can't restrict it to S[R] because I can't figure out how
// to make it support narrowing the return type)
export function getNodeTypeProperty<T, S extends NodeType, R extends keyof S>(
nodeType: S,
node: BoardNode,
property: R
): S[R] extends Pick<
S,
{
[K in keyof S]-?: undefined extends S[K] ? never : K;
}[keyof S]
>
? T
: T | undefined {
return typeof nodeType[property] === "function"
? (nodeType[property] as (node: BoardNode) => T)(node)
: (nodeType[property] as T);
}

View file

@ -73,13 +73,17 @@ export function getStartingChallenges(
export function getStartingBoards( export function getStartingBoards(
boards?: Record<string, Board> | Record<string, RawBoard> | undefined boards?: Record<string, Board> | Record<string, RawBoard> | undefined
): Record<string, Array<BoardNode>> { ): Record<string, BoardNode[]> {
return boards return boards
? Object.keys(boards).reduce((acc: Record<string, Array<BoardNode>>, curr: string): Record< ? Object.keys(boards).reduce((acc: Record<string, BoardNode[]>, curr: string): Record<
string, string,
Array<BoardNode> BoardNode[]
> => { > => {
acc[curr] = boards[curr].startNodes?.() || []; const nodes = boards[curr].startNodes?.() || [];
acc[curr] = nodes.map((node, index) => ({
id: index.toString(),
...node
})) as BoardNode[];
return acc; return acc;
}, {}) }, {})
: {}; : {};