WIP: Feature Rewrite #87
11 changed files with 820 additions and 1489 deletions
|
@ -7,3 +7,12 @@
|
|||
.modifier-toggle.collapsed {
|
||||
transform: translate(-5px, -5px) rotate(-90deg);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: monospace;
|
||||
font-size: 200%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||
}
|
||||
|
|
|
@ -1,92 +1,371 @@
|
|||
import Node from "components/Node.vue";
|
||||
import Spacer from "components/layout/Spacer.vue";
|
||||
import Board from "features/boards/Board.vue";
|
||||
import CircleProgress from "features/boards/CircleProgress.vue";
|
||||
import SVGNode from "features/boards/SVGNode.vue";
|
||||
import SquareProgress from "features/boards/SquareProgress.vue";
|
||||
import {
|
||||
NodePosition,
|
||||
placeInAvailableSpace,
|
||||
setupActions,
|
||||
setupDraggableNode,
|
||||
setupSelectable,
|
||||
setupUniqueIds
|
||||
} from "features/boards/board";
|
||||
import { jsx } from "features/feature";
|
||||
import { createResource, trackBest, trackOOMPS, trackTotal } from "features/resources/resource";
|
||||
import type { GenericTree } from "features/trees/tree";
|
||||
import { branchedResetPropagation, createTree } from "features/trees/tree";
|
||||
import { globalBus } from "game/events";
|
||||
import type { BaseLayer, GenericLayer } from "game/layers";
|
||||
import { createLayer } from "game/layers";
|
||||
import { persistent } from "game/persistence";
|
||||
import type { Player } from "game/player";
|
||||
import player from "game/player";
|
||||
import type { DecimalSource } from "util/bignum";
|
||||
import Decimal, { format, formatTime } from "util/bignum";
|
||||
import { render } from "util/vue";
|
||||
import { computed, toRaw } from "vue";
|
||||
import { ComponentPublicInstance, computed, ref, watch } from "vue";
|
||||
import prestige from "./layers/prestige";
|
||||
|
||||
type ANode = NodePosition & { id: number; links: number[]; type: "anode" };
|
||||
type BNode = NodePosition & { id: number; links: number[]; type: "bnode" };
|
||||
type NodeTypes = ANode | BNode;
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
export const main = createLayer("main", function (this: BaseLayer) {
|
||||
const points = createResource<DecimalSource>(10);
|
||||
const best = trackBest(points);
|
||||
const total = trackTotal(points);
|
||||
const board = ref<ComponentPublicInstance<typeof Board>>();
|
||||
|
||||
const pointGain = computed(() => {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let gain = new Decimal(1);
|
||||
return gain;
|
||||
});
|
||||
globalBus.on("update", diff => {
|
||||
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
|
||||
});
|
||||
const oomps = trackOOMPS(points, pointGain);
|
||||
const { select, deselect, selected } = setupSelectable<NodeTypes>();
|
||||
const {
|
||||
select: selectAction,
|
||||
deselect: deselectAction,
|
||||
selected: selectedAction
|
||||
} = setupSelectable<number>();
|
||||
|
||||
const tree = createTree(() => ({
|
||||
nodes: [[prestige.treeNode]],
|
||||
branches: [],
|
||||
onReset() {
|
||||
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10;
|
||||
best.value = points.value;
|
||||
total.value = points.value;
|
||||
watch(selected, selected => {
|
||||
if (selected == null) {
|
||||
deselectAction();
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
startDrag,
|
||||
endDrag,
|
||||
drag,
|
||||
nodeBeingDragged,
|
||||
hasDragged,
|
||||
receivingNodes,
|
||||
receivingNode,
|
||||
dragDelta
|
||||
} = setupDraggableNode<NodeTypes /* | typeof cNode*/>({
|
||||
board,
|
||||
isDraggable: function (node) {
|
||||
return nodes.value.includes(node);
|
||||
}
|
||||
});
|
||||
|
||||
// a nodes can be slotted into b nodes to draw a branch between them, with limited connections
|
||||
// a nodes can be selected and have an action to spawn a b node, and vice versa
|
||||
// Newly spawned nodes should find a safe spot to spawn, and display a link to their creator
|
||||
// a nodes use all the stuff circles used to have, and b diamonds
|
||||
// c node also exists but is a single Upgrade element that cannot be selected, but can be dragged
|
||||
// d nodes are a performance test - 1000 simple nodes that have no interactions
|
||||
// Make all nodes animate in (decorator? `fadeIn(feature)?)
|
||||
const nodes = persistent<NodeTypes[]>([{ id: 0, x: 0, y: 0, links: [], type: "anode" }]);
|
||||
const nodesById = computed<Record<string, NodeTypes>>(() =>
|
||||
nodes.value.reduce((acc, curr) => ({ ...acc, [curr.id]: curr }), {})
|
||||
);
|
||||
function mouseDownNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||
if (nodeBeingDragged.value == null) {
|
||||
startDrag(e, node);
|
||||
}
|
||||
deselect();
|
||||
}
|
||||
function mouseUpNode(e: MouseEvent | TouchEvent, node: NodeTypes) {
|
||||
if (!hasDragged.value) {
|
||||
endDrag();
|
||||
select(node);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
function getTranslateString(node: NodePosition, overrideSelected?: boolean) {
|
||||
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||
const isDragging = !isSelected && nodeBeingDragged.value === node;
|
||||
let x = node.x;
|
||||
let y = node.y;
|
||||
if (isDragging) {
|
||||
x += dragDelta.value.x;
|
||||
y += dragDelta.value.y;
|
||||
}
|
||||
return ` translate(${x}px,${y}px)`;
|
||||
}
|
||||
function getRotateString(rotation: number) {
|
||||
return ` rotate(${rotation}deg) `;
|
||||
}
|
||||
function getScaleString(node: NodePosition, overrideSelected?: boolean) {
|
||||
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||
return isSelected ? " scale(1.2)" : "";
|
||||
}
|
||||
function getOpacityString(node: NodePosition, overrideSelected?: boolean) {
|
||||
const isSelected = overrideSelected == null ? selected.value === node : overrideSelected;
|
||||
const isDragging = !isSelected && nodeBeingDragged.value === node;
|
||||
if (isDragging) {
|
||||
return "; opacity: 0.5;";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const renderANode = function (node: ANode) {
|
||||
return (
|
||||
<SVGNode
|
||||
style={`transform: ${getTranslateString(node)}${getOpacityString(node)}`}
|
||||
onMouseDown={e => mouseDownNode(e, node)}
|
||||
onMouseUp={e => mouseUpNode(e, node)}
|
||||
>
|
||||
<g style={`transform: ${getScaleString(node)}; transition-duration: 0s`}>
|
||||
{receivingNodes.value.includes(node) && (
|
||||
<circle
|
||||
r="58"
|
||||
fill="var(--background)"
|
||||
stroke={receivingNode.value === node ? "#0F0" : "#0F03"}
|
||||
stroke-width="2"
|
||||
/>
|
||||
)}
|
||||
<CircleProgress r={54.5} progress={0.5} stroke="var(--accent2)" />
|
||||
<circle
|
||||
r="50"
|
||||
fill="var(--raised-background)"
|
||||
stroke="var(--outline)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
{selected.value === node && selectedAction.value === 0 && (
|
||||
<text y="140" fill="var(--foreground)" class="node-text">
|
||||
Spawn B Node
|
||||
</text>
|
||||
)}
|
||||
<text fill="var(--foreground)" class="node-text">
|
||||
A
|
||||
</text>
|
||||
</SVGNode>
|
||||
);
|
||||
};
|
||||
const aActions = setupActions({
|
||||
node: selected,
|
||||
shouldShowActions: () => selected.value?.type === "anode",
|
||||
actions(node) {
|
||||
return [
|
||||
p => (
|
||||
<g
|
||||
style={`transform: ${getTranslateString(
|
||||
p,
|
||||
selectedAction.value === 0
|
||||
)}${getScaleString(p, selectedAction.value === 0)}`}
|
||||
onClick={() => {
|
||||
if (selectedAction.value === 0) {
|
||||
spawnBNode(node);
|
||||
} else {
|
||||
selectAction(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle fill="black" r="20"></circle>
|
||||
<text fill="white" class="material-icons" x="-12" y="12">
|
||||
add
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
];
|
||||
},
|
||||
resetPropagation: branchedResetPropagation
|
||||
})) as GenericTree;
|
||||
distance: 100
|
||||
});
|
||||
const sqrtTwo = Math.sqrt(2);
|
||||
const renderBNode = function (node: BNode) {
|
||||
return (
|
||||
<SVGNode
|
||||
style={`transform: ${getTranslateString(node)}${getOpacityString(node)}`}
|
||||
onMouseDown={e => mouseDownNode(e, node)}
|
||||
onMouseUp={e => mouseUpNode(e, node)}
|
||||
>
|
||||
<g
|
||||
style={`transform: ${getScaleString(node)}${getRotateString(
|
||||
45
|
||||
)}; transition-duration: 0s`}
|
||||
>
|
||||
{receivingNodes.value.includes(node) && (
|
||||
<rect
|
||||
width={50 * sqrtTwo + 16}
|
||||
height={50 * sqrtTwo + 16}
|
||||
style={`translate(${(-50 * sqrtTwo + 16) / 2}, ${
|
||||
(-50 * sqrtTwo + 16) / 2
|
||||
})`}
|
||||
fill="var(--background)"
|
||||
stroke={receivingNode.value === node ? "#0F0" : "#0F03"}
|
||||
stroke-width="2"
|
||||
/>
|
||||
)}
|
||||
<SquareProgress
|
||||
size={50 * sqrtTwo + 9}
|
||||
progress={0.5}
|
||||
stroke="var(--accent2)"
|
||||
/>
|
||||
<rect
|
||||
width={50 * sqrtTwo}
|
||||
height={50 * sqrtTwo}
|
||||
style={`transform: translate(${(-50 * sqrtTwo) / 2}px, ${
|
||||
(-50 * sqrtTwo) / 2
|
||||
}px)`}
|
||||
fill="var(--raised-background)"
|
||||
stroke="var(--outline)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
{selected.value === node && selectedAction.value === 0 && (
|
||||
<text y="140" fill="var(--foreground)" class="node-text">
|
||||
Spawn A Node
|
||||
</text>
|
||||
)}
|
||||
<text fill="var(--foreground)" class="node-text">
|
||||
B
|
||||
</text>
|
||||
</SVGNode>
|
||||
);
|
||||
};
|
||||
const bActions = setupActions({
|
||||
node: selected,
|
||||
shouldShowActions: () => selected.value?.type === "bnode",
|
||||
actions(node) {
|
||||
return [
|
||||
p => (
|
||||
<g
|
||||
style={`transform: ${getTranslateString(
|
||||
p,
|
||||
selectedAction.value === 0
|
||||
)}${getScaleString(p, selectedAction.value === 0)}`}
|
||||
onClick={() => {
|
||||
if (selectedAction.value === 0) {
|
||||
spawnANode(node);
|
||||
} else {
|
||||
selectAction(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle fill="white" r="20"></circle>
|
||||
<text fill="black" class="material-icons" x="-12" y="12">
|
||||
add
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
];
|
||||
},
|
||||
distance: 100
|
||||
});
|
||||
function spawnANode(parent: NodeTypes) {
|
||||
const node: ANode = {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
type: "anode",
|
||||
links: [parent.id],
|
||||
id: nextId.value
|
||||
};
|
||||
placeInAvailableSpace(node, nodes.value);
|
||||
nodes.value.push(node);
|
||||
}
|
||||
function spawnBNode(parent: NodeTypes) {
|
||||
const node: BNode = {
|
||||
x: parent.x,
|
||||
y: parent.y,
|
||||
type: "bnode",
|
||||
links: [parent.id],
|
||||
id: nextId.value
|
||||
};
|
||||
placeInAvailableSpace(node, nodes.value);
|
||||
nodes.value.push(node);
|
||||
}
|
||||
|
||||
// const cNode = createUpgrade(() => ({
|
||||
// requirements: createCostRequirement(() => ({ cost: 10, resource: points })),
|
||||
// style: {
|
||||
// x: "100px",
|
||||
// y: "100px"
|
||||
// }
|
||||
// }));
|
||||
// makeDraggable(cNode); // TODO make decorator
|
||||
|
||||
// const dNodes;
|
||||
|
||||
const links = jsx(() => (
|
||||
<>
|
||||
{nodes.value
|
||||
.reduce(
|
||||
(acc, curr) => [
|
||||
...acc,
|
||||
...curr.links.map(l => ({ from: curr, to: nodesById.value[l] }))
|
||||
],
|
||||
[] as { from: NodeTypes; to: NodeTypes }[]
|
||||
)
|
||||
.map(link => (
|
||||
<line
|
||||
stroke="white"
|
||||
stroke-width={4}
|
||||
x1={
|
||||
nodeBeingDragged.value === link.from
|
||||
? dragDelta.value.x + link.from.x
|
||||
: link.from.x
|
||||
}
|
||||
y1={
|
||||
nodeBeingDragged.value === link.from
|
||||
? dragDelta.value.y + link.from.y
|
||||
: link.from.y
|
||||
}
|
||||
x2={
|
||||
nodeBeingDragged.value === link.to
|
||||
? dragDelta.value.x + link.to.x
|
||||
: link.to.x
|
||||
}
|
||||
y2={
|
||||
nodeBeingDragged.value === link.to
|
||||
? dragDelta.value.y + link.to.y
|
||||
: link.to.y
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const nextId = setupUniqueIds(() => nodes.value);
|
||||
|
||||
function filterNodes(n: NodeTypes) {
|
||||
return n !== nodeBeingDragged.value && n !== selected.value;
|
||||
}
|
||||
|
||||
function renderNode(node: NodeTypes | undefined) {
|
||||
if (node == undefined) {
|
||||
return undefined;
|
||||
} else if (node.type === "anode") {
|
||||
return renderANode(node);
|
||||
} else if (node.type === "bnode") {
|
||||
return renderBNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Tree",
|
||||
links: tree.links,
|
||||
display: jsx(() => (
|
||||
<>
|
||||
{player.devSpeed === 0 ? (
|
||||
<div>
|
||||
Game Paused
|
||||
<Node id="paused" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? (
|
||||
<div>
|
||||
Dev Speed: {format(player.devSpeed)}x
|
||||
<Node id="devspeed" />
|
||||
</div>
|
||||
) : null}
|
||||
{player.offlineTime != null && player.offlineTime !== 0 ? (
|
||||
<div>
|
||||
Offline Time: {formatTime(player.offlineTime)}
|
||||
<Node id="offline" />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{Decimal.lt(points.value, "1e1000") ? <span>You have </span> : null}
|
||||
<h2>{format(points.value)}</h2>
|
||||
{Decimal.lt(points.value, "1e1e6") ? <span> points</span> : null}
|
||||
</div>
|
||||
{Decimal.gt(pointGain.value, 0) ? (
|
||||
<div>
|
||||
({oomps.value})
|
||||
<Node id="oomps" />
|
||||
</div>
|
||||
) : null}
|
||||
<Spacer />
|
||||
{render(tree)}
|
||||
<Board
|
||||
onDrag={drag}
|
||||
onMouseDown={deselect}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
ref={board}
|
||||
>
|
||||
<SVGNode>{links()}</SVGNode>
|
||||
{nodes.value.filter(filterNodes).map(renderNode)}
|
||||
<SVGNode>
|
||||
{aActions()}
|
||||
{bActions()}
|
||||
</SVGNode>
|
||||
{renderNode(selected.value)}
|
||||
{renderNode(nodeBeingDragged.value)}
|
||||
</Board>
|
||||
</>
|
||||
)),
|
||||
points,
|
||||
best,
|
||||
total,
|
||||
oomps,
|
||||
tree
|
||||
boardNodes: nodes
|
||||
// cNode
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,278 +1,75 @@
|
|||
<template>
|
||||
<panZoom
|
||||
v-if="isVisible(visibility)"
|
||||
:style="[
|
||||
{
|
||||
width,
|
||||
height
|
||||
},
|
||||
style
|
||||
]"
|
||||
:class="classes"
|
||||
selector=".g1"
|
||||
selector=".stage"
|
||||
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
|
||||
ref="stage"
|
||||
@init="onInit"
|
||||
@mousemove="drag"
|
||||
@touchmove="drag"
|
||||
@mousedown="(e: MouseEvent) => mouseDown(e)"
|
||||
@touchstart="(e: TouchEvent) => mouseDown(e)"
|
||||
@mouseup="() => endDragging(unref(draggingNode))"
|
||||
@touchend.passive="() => endDragging(unref(draggingNode))"
|
||||
@mouseleave="() => endDragging(unref(draggingNode), true)"
|
||||
@mousemove="(e: MouseEvent) => emit('drag', e)"
|
||||
@touchmove="(e: TouchEvent) => emit('drag', e)"
|
||||
@mouseleave="(e: MouseEvent) => emit('mouseLeave', e)"
|
||||
@mouseup="(e: MouseEvent) => emit('mouseUp', e)"
|
||||
@touchend.passive="(e: TouchEvent) => emit('mouseUp', e)"
|
||||
>
|
||||
<svg class="stage" width="100%" height="100%">
|
||||
<g class="g1">
|
||||
<transition-group name="link" appear>
|
||||
<g
|
||||
v-for="link in unref(links) || []"
|
||||
:key="`${link.startNode.id}-${link.endNode.id}`"
|
||||
>
|
||||
<BoardLinkVue
|
||||
:link="link"
|
||||
:dragging="unref(draggingNode)"
|
||||
:dragged="
|
||||
link.startNode === unref(draggingNode) ||
|
||||
link.endNode === unref(draggingNode)
|
||||
? dragged
|
||||
: undefined
|
||||
"
|
||||
<div
|
||||
class="event-listener"
|
||||
@mousedown="(e: MouseEvent) => emit('mouseDown', e)"
|
||||
@touchstart="(e: TouchEvent) => emit('mouseDown', e)"
|
||||
/>
|
||||
</g>
|
||||
</transition-group>
|
||||
<transition-group name="grow" :duration="500" appear>
|
||||
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
|
||||
<BoardNodeVue
|
||||
:node="node"
|
||||
:nodeType="types[node.type]"
|
||||
:dragging="unref(draggingNode)"
|
||||
:dragged="unref(draggingNode) === node ? dragged : undefined"
|
||||
:hasDragged="unref(draggingNode) == null ? false : hasDragged"
|
||||
:receivingNode="unref(receivingNode) === node"
|
||||
:isSelected="unref(selectedNode) === node"
|
||||
:selectedAction="
|
||||
unref(selectedNode) === node ? unref(selectedAction) : null
|
||||
"
|
||||
@mouseDown="mouseDown"
|
||||
@endDragging="endDragging"
|
||||
@clickAction="(actionId: string) => clickAction(node, actionId)"
|
||||
/>
|
||||
</g>
|
||||
</transition-group>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="stage">
|
||||
<slot />
|
||||
</div>
|
||||
</panZoom>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
BoardData,
|
||||
BoardNode,
|
||||
BoardNodeLink,
|
||||
GenericBoardNodeAction,
|
||||
GenericNodeType
|
||||
} from "features/boards/board";
|
||||
import { getNodeProperty } from "features/boards/board";
|
||||
import type { StyleValue } from "features/feature";
|
||||
import { Visibility, isVisible } from "features/feature";
|
||||
import type { ProcessedComputable } from "util/computed";
|
||||
import { Ref, computed, ref, toRefs, unref, watchEffect } from "vue";
|
||||
import BoardLinkVue from "./BoardLink.vue";
|
||||
import BoardNodeVue from "./BoardNode.vue";
|
||||
import type { PanZoom } from "panzoom";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
// Required to make sure panzoom component gets registered:
|
||||
import "features/boards/board";
|
||||
|
||||
const _props = defineProps<{
|
||||
nodes: Ref<BoardNode[]>;
|
||||
types: Record<string, GenericNodeType>;
|
||||
state: Ref<BoardData>;
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
width?: ProcessedComputable<string>;
|
||||
height?: ProcessedComputable<string>;
|
||||
style?: ProcessedComputable<StyleValue>;
|
||||
classes?: ProcessedComputable<Record<string, boolean>>;
|
||||
links: Ref<BoardNodeLink[] | null>;
|
||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||
selectedNode: Ref<BoardNode | null>;
|
||||
draggingNode: Ref<BoardNode | null>;
|
||||
receivingNode: Ref<BoardNode | null>;
|
||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||
setReceivingNode: (node: BoardNode | null) => void;
|
||||
setDraggingNode: (node: BoardNode | null) => void;
|
||||
defineExpose({
|
||||
panZoomInstance: computed(() => stage.value?.panZoomInstance)
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(event: "drag", e: MouseEvent | TouchEvent): void;
|
||||
(event: "mouseDown", e: MouseEvent | TouchEvent): void;
|
||||
(event: "mouseUp", e: MouseEvent | TouchEvent): void;
|
||||
(event: "mouseLeave", e: MouseEvent | TouchEvent): void;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||
const dragged = ref({ x: 0, y: 0 });
|
||||
const hasDragged = ref(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stage = ref<any>(null);
|
||||
const stage = ref<{ panZoomInstance: PanZoom } & ComponentPublicInstance<HTMLElement>>();
|
||||
|
||||
const sortedNodes = computed(() => {
|
||||
const nodes = props.nodes.value.slice();
|
||||
if (props.selectedNode.value) {
|
||||
const node = nodes.splice(nodes.indexOf(props.selectedNode.value), 1)[0];
|
||||
nodes.push(node);
|
||||
}
|
||||
if (props.draggingNode.value) {
|
||||
const node = nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0];
|
||||
nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
const node = props.draggingNode.value;
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: node.position.x + dragged.value.x,
|
||||
y: node.position.y + dragged.value.y
|
||||
};
|
||||
let smallestDistance = Number.MAX_VALUE;
|
||||
|
||||
props.setReceivingNode.value(
|
||||
props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
|
||||
if (curr.id === node.id) {
|
||||
return smallest;
|
||||
}
|
||||
const nodeType = props.types.value[curr.type];
|
||||
const canAccept = getNodeProperty(nodeType.canAccept, curr, node);
|
||||
if (!canAccept) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
const distanceSquared =
|
||||
Math.pow(position.x - curr.position.x, 2) +
|
||||
Math.pow(position.y - curr.position.y, 2);
|
||||
let size = getNodeProperty(nodeType.size, curr);
|
||||
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
smallestDistance = distanceSquared;
|
||||
return curr;
|
||||
}, null)
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function onInit(panzoomInstance: any) {
|
||||
function onInit(panzoomInstance: PanZoom) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
panzoomInstance.setTransformOrigin(null);
|
||||
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2);
|
||||
}
|
||||
|
||||
function mouseDown(e: MouseEvent | TouchEvent, node: BoardNode | null = null, draggable = false) {
|
||||
if (props.draggingNode.value == null) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
dragged.value = { x: 0, y: 0 };
|
||||
hasDragged.value = false;
|
||||
|
||||
if (draggable) {
|
||||
props.setDraggingNode.value(node);
|
||||
}
|
||||
}
|
||||
if (node != null) {
|
||||
props.state.value.selectedNode = null;
|
||||
props.state.value.selectedAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drag(e: MouseEvent | TouchEvent) {
|
||||
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
endDragging(props.draggingNode.value);
|
||||
props.mousePosition.value = null;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
|
||||
props.mousePosition.value = {
|
||||
x: (clientX - x) / scale,
|
||||
y: (clientY - y) / scale
|
||||
};
|
||||
|
||||
dragged.value = {
|
||||
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
|
||||
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
|
||||
};
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
|
||||
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
|
||||
hasDragged.value = true;
|
||||
}
|
||||
|
||||
if (props.draggingNode.value != null) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function endDragging(node: BoardNode | null, mouseLeave = false) {
|
||||
if (props.draggingNode.value != null && props.draggingNode.value === node) {
|
||||
if (props.receivingNode.value == null) {
|
||||
props.draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
|
||||
props.draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
|
||||
}
|
||||
|
||||
const nodes = props.nodes.value;
|
||||
nodes.push(nodes.splice(nodes.indexOf(props.draggingNode.value), 1)[0]);
|
||||
|
||||
if (props.receivingNode.value) {
|
||||
props.types.value[props.receivingNode.value.type].onDrop?.(
|
||||
props.receivingNode.value,
|
||||
props.draggingNode.value
|
||||
);
|
||||
}
|
||||
|
||||
props.setDraggingNode.value(null);
|
||||
} else if (!hasDragged.value && !mouseLeave) {
|
||||
props.state.value.selectedNode = null;
|
||||
props.state.value.selectedAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clickAction(node: BoardNode, actionId: string) {
|
||||
if (props.state.value.selectedAction === actionId) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
unref(props.selectedAction)!.onClick(unref(props.selectedNode)!);
|
||||
} else {
|
||||
props.state.value = { ...props.state.value, selectedAction: actionId };
|
||||
}
|
||||
panzoomInstance.moveTo(0, stage.value?.$el.clientHeight / 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-listener {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.stage {
|
||||
transition-duration: 0s;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.vue-pan-zoom-item {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vue-pan-zoom-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -283,12 +80,14 @@ function clickAction(node: BoardNode, actionId: string) {
|
|||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.g1 {
|
||||
transition-duration: 0s;
|
||||
.stage > * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.link-enter-from,
|
||||
.link-leave-to {
|
||||
opacity: 0;
|
||||
/* "Only" child (excluding resize listener) */
|
||||
.layer-tab > .vue-pan-zoom-item:first-child:nth-last-child(2) {
|
||||
width: calc(100% + 20px);
|
||||
height: calc(100% + 100px);
|
||||
margin: -50px -10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
<template>
|
||||
<line
|
||||
class="link"
|
||||
v-bind="linkProps"
|
||||
:class="{ pulsing: link.pulsing }"
|
||||
:x1="startPosition.x"
|
||||
:y1="startPosition.y"
|
||||
:x2="endPosition.x"
|
||||
:y2="endPosition.y"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BoardNode, BoardNodeLink } from "features/boards/board";
|
||||
import { kebabifyObject } from "util/vue";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
link: BoardNodeLink;
|
||||
dragging: BoardNode | null;
|
||||
dragged?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const startPosition = computed(() => {
|
||||
const position = { ...props.link.value.startNode.position };
|
||||
if (props.link.value.offsetStart) {
|
||||
position.x += unref(props.link.value.offsetStart).x;
|
||||
position.y += unref(props.link.value.offsetStart).y;
|
||||
}
|
||||
if (props.dragging?.value === props.link.value.startNode) {
|
||||
position.x += props.dragged?.value?.x ?? 0;
|
||||
position.y += props.dragged?.value?.y ?? 0;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const endPosition = computed(() => {
|
||||
const position = { ...props.link.value.endNode.position };
|
||||
if (props.link.value.offsetEnd) {
|
||||
position.x += unref(props.link.value.offsetEnd).x;
|
||||
position.y += unref(props.link.value.offsetEnd).y;
|
||||
}
|
||||
if (props.dragging?.value === props.link.value.endNode) {
|
||||
position.x += props.dragged?.value?.x ?? 0;
|
||||
position.y += props.dragged?.value?.y ?? 0;
|
||||
}
|
||||
return position;
|
||||
});
|
||||
|
||||
const linkProps = computed(() => kebabifyObject(_props.link as unknown as Record<string, unknown>));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.link {
|
||||
transition-duration: 0s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link.pulsing {
|
||||
animation: pulsing 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,339 +0,0 @@
|
|||
<template>
|
||||
<!-- Ugly casting to prevent TS compiler error about style because vue doesn't think it supports arrays when it does -->
|
||||
<g
|
||||
class="boardnode"
|
||||
:class="{ [node.type]: true, isSelected, isDraggable, ...classes }"
|
||||
:style="[{ opacity: dragging?.id === node.id && hasDragged ? 0.5 : 1 }, style ?? []] as unknown as (string | CSSProperties)"
|
||||
:transform="`translate(${position.x},${position.y})${isSelected ? ' scale(1.2)' : ''}`"
|
||||
>
|
||||
<BoardNodeAction
|
||||
:actions="actions ?? []"
|
||||
:is-selected="isSelected"
|
||||
:node="node"
|
||||
:node-type="nodeType"
|
||||
:selected-action="selectedAction"
|
||||
@click-action="(actionId: string) => emit('clickAction', actionId)"
|
||||
/>
|
||||
|
||||
<g
|
||||
class="node-container"
|
||||
@mousedown="mouseDown"
|
||||
@touchstart.passive="mouseDown"
|
||||
@mouseup="mouseUp"
|
||||
@touchend.passive="mouseUp"
|
||||
>
|
||||
<g v-if="shape === Shape.Circle">
|
||||
<circle
|
||||
v-if="canAccept"
|
||||
class="receiver"
|
||||
:r="size + 8"
|
||||
:fill="backgroundColor"
|
||||
:stroke="receivingNode ? '#0F0' : '#0F03'"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="body"
|
||||
:r="size"
|
||||
:fill="fillColor"
|
||||
:stroke="outlineColor"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="progress progressFill"
|
||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||
:r="Math.max(size * progress - 2, 0)"
|
||||
:fill="progressColor"
|
||||
/>
|
||||
<circle
|
||||
v-else
|
||||
:r="size + 4.5"
|
||||
class="progress progressRing"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="(size + 4.5) * 2 * Math.PI"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="
|
||||
(size + 4.5) * 2 * Math.PI - progress * (size + 4.5) * 2 * Math.PI
|
||||
"
|
||||
:stroke="progressColor"
|
||||
/>
|
||||
</g>
|
||||
<g v-else-if="shape === Shape.Diamond" transform="rotate(45, 0, 0)">
|
||||
<rect
|
||||
v-if="canAccept"
|
||||
class="receiver"
|
||||
: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
|
||||
class="body"
|
||||
:width="size * sqrtTwo"
|
||||
:height="size * sqrtTwo"
|
||||
:transform="`translate(${(-size * sqrtTwo) / 2}, ${(-size * sqrtTwo) / 2})`"
|
||||
:fill="fillColor"
|
||||
:stroke="outlineColor"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="progressDisplay === ProgressDisplay.Fill"
|
||||
class="progress progressFill"
|
||||
:width="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||
:height="Math.max(size * sqrtTwo * progress - 2, 0)"
|
||||
:transform="`translate(${-Math.max(size * sqrtTwo * progress - 2, 0) / 2}, ${
|
||||
-Math.max(size * sqrtTwo * progress - 2, 0) / 2
|
||||
})`"
|
||||
:fill="progressColor"
|
||||
/>
|
||||
<rect
|
||||
v-else
|
||||
class="progress progressDiamond"
|
||||
:width="size * sqrtTwo + 9"
|
||||
:height="size * sqrtTwo + 9"
|
||||
:transform="`translate(${-(size * sqrtTwo + 9) / 2}, ${
|
||||
-(size * sqrtTwo + 9) / 2
|
||||
})`"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="(size * sqrtTwo + 9) * 4"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="
|
||||
(size * sqrtTwo + 9) * 4 - progress * (size * sqrtTwo + 9) * 4
|
||||
"
|
||||
:stroke="progressColor"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<text :fill="titleColor" class="node-title">{{ title }}</text>
|
||||
</g>
|
||||
|
||||
<transition name="fade" appear>
|
||||
<g v-if="label">
|
||||
<text
|
||||
:fill="label.color ?? titleColor"
|
||||
class="node-title"
|
||||
:class="{ pulsing: label.pulsing }"
|
||||
:y="-size - 20"
|
||||
>{{ label.text }}
|
||||
</text>
|
||||
</g>
|
||||
</transition>
|
||||
|
||||
<transition name="fade" appear>
|
||||
<text
|
||||
v-if="isSelected && selectedAction"
|
||||
:fill="confirmationLabel.color ?? titleColor"
|
||||
class="node-title"
|
||||
:class="{ pulsing: confirmationLabel.pulsing }"
|
||||
:y="size + 75"
|
||||
>{{ confirmationLabel.text }}</text
|
||||
>
|
||||
</transition>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import themes from "data/themes";
|
||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
||||
import { ProgressDisplay, Shape, getNodeProperty } from "features/boards/board";
|
||||
import { isVisible } from "features/feature";
|
||||
import settings from "game/settings";
|
||||
import { CSSProperties, computed, toRefs, unref, watch } from "vue";
|
||||
import BoardNodeAction from "./BoardNodeAction.vue";
|
||||
|
||||
const sqrtTwo = Math.sqrt(2);
|
||||
|
||||
const _props = defineProps<{
|
||||
node: BoardNode;
|
||||
nodeType: GenericNodeType;
|
||||
dragging: BoardNode | null;
|
||||
dragged?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
hasDragged?: boolean;
|
||||
receivingNode?: boolean;
|
||||
isSelected: boolean;
|
||||
selectedAction: GenericBoardNodeAction | null;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
const emit = defineEmits<{
|
||||
(e: "mouseDown", event: MouseEvent | TouchEvent, node: BoardNode, isDraggable: boolean): void;
|
||||
(e: "endDragging", node: BoardNode): void;
|
||||
(e: "clickAction", actionId: string): void;
|
||||
}>();
|
||||
|
||||
const isDraggable = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.draggable, unref(props.node))
|
||||
);
|
||||
|
||||
watch(isDraggable, value => {
|
||||
const node = unref(props.node);
|
||||
if (unref(props.dragging) === node && !value) {
|
||||
emit("endDragging", node);
|
||||
}
|
||||
});
|
||||
|
||||
const actions = computed(() => {
|
||||
const node = unref(props.node);
|
||||
return getNodeProperty(props.nodeType.value.actions, node)?.filter(action =>
|
||||
isVisible(getNodeProperty(action.visibility, node))
|
||||
);
|
||||
});
|
||||
|
||||
const position = computed(() => {
|
||||
const node = unref(props.node);
|
||||
|
||||
if (
|
||||
getNodeProperty(props.nodeType.value.draggable, node) &&
|
||||
unref(props.dragging)?.id === node.id &&
|
||||
unref(props.dragged) != null
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const { x, y } = unref(props.dragged)!;
|
||||
return {
|
||||
x: node.position.x + Math.round(x / 25) * 25,
|
||||
y: node.position.y + Math.round(y / 25) * 25
|
||||
};
|
||||
}
|
||||
return node.position;
|
||||
});
|
||||
|
||||
const shape = computed(() => getNodeProperty(props.nodeType.value.shape, unref(props.node)));
|
||||
const title = computed(() => getNodeProperty(props.nodeType.value.title, unref(props.node)));
|
||||
const label = computed(
|
||||
() =>
|
||||
(props.isSelected.value
|
||||
? unref(props.selectedAction) &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getNodeProperty(unref(props.selectedAction)!.tooltip, unref(props.node))
|
||||
: null) ?? getNodeProperty(props.nodeType.value.label, unref(props.node))
|
||||
);
|
||||
const confirmationLabel = computed(() =>
|
||||
getNodeProperty(
|
||||
unref(props.selectedAction)?.confirmationLabel ?? {
|
||||
text: "Tap again to confirm"
|
||||
},
|
||||
unref(props.node)
|
||||
)
|
||||
);
|
||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
||||
const progress = computed(
|
||||
() => getNodeProperty(props.nodeType.value.progress, unref(props.node)) ?? 0
|
||||
);
|
||||
const backgroundColor = computed(() => themes[settings.theme].variables["--background"]);
|
||||
const outlineColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--outline"]
|
||||
);
|
||||
const fillColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.fillColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--raised-background"]
|
||||
);
|
||||
const progressColor = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.progressColor, unref(props.node))
|
||||
);
|
||||
const titleColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--foreground"]
|
||||
);
|
||||
const progressDisplay = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.progressDisplay, unref(props.node))
|
||||
);
|
||||
const canAccept = computed(
|
||||
() =>
|
||||
props.dragging.value != null &&
|
||||
unref(props.hasDragged) &&
|
||||
getNodeProperty(props.nodeType.value.canAccept, unref(props.node), props.dragging.value)
|
||||
);
|
||||
const style = computed(() => getNodeProperty(props.nodeType.value.style, unref(props.node)));
|
||||
const classes = computed(() => getNodeProperty(props.nodeType.value.classes, unref(props.node)));
|
||||
|
||||
function mouseDown(e: MouseEvent | TouchEvent) {
|
||||
emit("mouseDown", e, props.node.value, isDraggable.value);
|
||||
}
|
||||
|
||||
function mouseUp(e: MouseEvent | TouchEvent) {
|
||||
if (!props.hasDragged?.value) {
|
||||
emit("endDragging", props.node.value);
|
||||
props.nodeType.value.onClick?.(props.node.value);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.boardnode {
|
||||
cursor: pointer;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
.boardnode:hover .body {
|
||||
fill: var(--highlighted);
|
||||
}
|
||||
|
||||
.boardnode.isSelected .body {
|
||||
fill: var(--accent1) !important;
|
||||
}
|
||||
|
||||
.boardnode:not(.isDraggable) .body {
|
||||
fill: var(--locked);
|
||||
}
|
||||
|
||||
.node-title {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-family: monospace;
|
||||
font-size: 200%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(3px 3px 2px var(--tooltip-background));
|
||||
}
|
||||
|
||||
.progress {
|
||||
transition-duration: 0.05s;
|
||||
}
|
||||
|
||||
.progressRing {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pulsing {
|
||||
animation: pulsing 2s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes pulsing {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.grow-enter-from .node-container,
|
||||
.grow-leave-to .node-container {
|
||||
transform: scale(0);
|
||||
}
|
||||
</style>
|
|
@ -1,109 +0,0 @@
|
|||
<template>
|
||||
<transition name="actions" appear>
|
||||
<g v-if="isSelected && actions">
|
||||
<g
|
||||
v-for="(action, index) in actions"
|
||||
:key="action.id"
|
||||
class="action"
|
||||
:class="{ selected: selectedAction?.id === action.id }"
|
||||
:transform="`translate(
|
||||
${
|
||||
(-size - 30) *
|
||||
Math.sin(((actions.length - 1) / 2 - index) * actionDistance)
|
||||
},
|
||||
${
|
||||
(size + 30) *
|
||||
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)
|
||||
}
|
||||
)`"
|
||||
@mousedown="e => performAction(e, action)"
|
||||
@touchstart="e => performAction(e, action)"
|
||||
@mouseup="e => actionMouseUp(e, action)"
|
||||
@touchend.stop="e => actionMouseUp(e, action)"
|
||||
>
|
||||
<circle
|
||||
:fill="getNodeProperty(action.fillColor, node)"
|
||||
r="20"
|
||||
:stroke-width="selectedAction?.id === action.id ? 4 : 0"
|
||||
:stroke="outlineColor"
|
||||
/>
|
||||
<text :fill="titleColor" class="material-icons">{{
|
||||
getNodeProperty(action.icon, node)
|
||||
}}</text>
|
||||
</g>
|
||||
</g>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import themes from "data/themes";
|
||||
import type { BoardNode, GenericBoardNodeAction, GenericNodeType } from "features/boards/board";
|
||||
import { getNodeProperty } from "features/boards/board";
|
||||
import settings from "game/settings";
|
||||
import { computed, toRefs, unref } from "vue";
|
||||
|
||||
const _props = defineProps<{
|
||||
node: BoardNode;
|
||||
nodeType: GenericNodeType;
|
||||
actions?: GenericBoardNodeAction[];
|
||||
isSelected: boolean;
|
||||
selectedAction: GenericBoardNodeAction | null;
|
||||
}>();
|
||||
const props = toRefs(_props);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "clickAction", actionId: string): void;
|
||||
}>();
|
||||
|
||||
const size = computed(() => getNodeProperty(props.nodeType.value.size, unref(props.node)));
|
||||
const outlineColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.outlineColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--outline"]
|
||||
);
|
||||
const titleColor = computed(
|
||||
() =>
|
||||
getNodeProperty(props.nodeType.value.titleColor, unref(props.node)) ??
|
||||
themes[settings.theme].variables["--foreground"]
|
||||
);
|
||||
const actionDistance = computed(() =>
|
||||
getNodeProperty(props.nodeType.value.actionDistance, unref(props.node))
|
||||
);
|
||||
|
||||
function performAction(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||
emit("clickAction", action.id);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function actionMouseUp(e: MouseEvent | TouchEvent, action: GenericBoardNodeAction) {
|
||||
if (unref(props.selectedAction)?.id === action.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action:not(.boardnode):hover circle,
|
||||
.action:not(.boardnode).selected circle {
|
||||
r: 25;
|
||||
}
|
||||
|
||||
.action:not(.boardnode):hover text,
|
||||
.action:not(.boardnode).selected text {
|
||||
font-size: 187.5%; /* 150% * 1.25 */
|
||||
}
|
||||
|
||||
.action:not(.boardnode) text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.actions-enter-from .action,
|
||||
.actions-leave-to .action {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
</style>
|
29
src/features/boards/CircleProgress.vue
Normal file
29
src/features/boards/CircleProgress.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<circle
|
||||
:r="r"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="r * 2 * Math.PI"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="r * 2 * Math.PI - progress * r * 2 * Math.PI"
|
||||
:stroke="stroke"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SVGAttributes } from "vue";
|
||||
|
||||
interface CircleProgressProps extends SVGAttributes {
|
||||
r: number;
|
||||
progress: number;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
defineProps<CircleProgressProps>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
circle {
|
||||
transition-duration: 0.05s;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
</style>
|
27
src/features/boards/SVGNode.vue
Normal file
27
src/features/boards/SVGNode.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<svg
|
||||
@mousedown="e => emit('mouseDown', e)"
|
||||
@touchstart.passive="e => emit('mouseDown', e)"
|
||||
@mouseup="e => emit('mouseUp', e)"
|
||||
@touchend.passive="e => emit('mouseUp', e)"
|
||||
width="1"
|
||||
height="1"
|
||||
>
|
||||
<slot />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "mouseDown", event: MouseEvent | TouchEvent): void;
|
||||
(e: "mouseUp", event: MouseEvent | TouchEvent): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
cursor: pointer;
|
||||
transition-duration: 0s;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
30
src/features/boards/SquareProgress.vue
Normal file
30
src/features/boards/SquareProgress.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<rect
|
||||
:width="size"
|
||||
:height="size"
|
||||
:transform="`translate(${-size / 2}, ${-size / 2})`"
|
||||
fill="transparent"
|
||||
:stroke-dasharray="size * 4"
|
||||
:stroke-width="5"
|
||||
:stroke-dashoffset="size * 4 - progress * size * 4"
|
||||
:stroke="stroke"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SVGAttributes } from "vue";
|
||||
|
||||
interface SquareProgressProps extends SVGAttributes {
|
||||
size: number;
|
||||
progress: number;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
defineProps<SquareProgressProps>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
rect {
|
||||
transition-duration: 0.05s;
|
||||
}
|
||||
</style>
|
|
@ -1,631 +0,0 @@
|
|||
import BoardComponent from "features/boards/Board.vue";
|
||||
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
|
||||
import {
|
||||
Component,
|
||||
findFeatures,
|
||||
GatherProps,
|
||||
getUniqueID,
|
||||
setDefault,
|
||||
Visibility
|
||||
} from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import { DefaultValue, deletePersistent, Persistent, State } from "game/persistence";
|
||||
import { persistent } from "game/persistence";
|
||||
import type { Unsubscribe } from "nanoevents";
|
||||
import { Direction, isFunction } from "util/common";
|
||||
import type {
|
||||
Computable,
|
||||
GetComputableType,
|
||||
GetComputableTypeWithDefault,
|
||||
ProcessedComputable
|
||||
} from "util/computed";
|
||||
import { processComputable } from "util/computed";
|
||||
import { createLazyProxy } from "util/proxies";
|
||||
import { computed, isRef, ref, Ref, unref } from "vue";
|
||||
import panZoom from "vue-panzoom";
|
||||
import type { Link } from "../links/links";
|
||||
|
||||
globalBus.on("setupVue", app => panZoom.install(app));
|
||||
|
||||
/** A symbol used to identify {@link Board} features. */
|
||||
export const BoardType = Symbol("Board");
|
||||
|
||||
/**
|
||||
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
||||
*/
|
||||
export type NodeComputable<T, S extends unknown[] = []> =
|
||||
| Computable<T>
|
||||
| ((node: BoardNode, ...args: S) => T);
|
||||
|
||||
/** Ways to display progress of an action with a duration. */
|
||||
export enum ProgressDisplay {
|
||||
Outline = "Outline",
|
||||
Fill = "Fill"
|
||||
}
|
||||
|
||||
/** Node shapes. */
|
||||
export enum Shape {
|
||||
Circle = "Circle",
|
||||
Diamond = "Triangle"
|
||||
}
|
||||
|
||||
/** An object representing a node on the board. */
|
||||
export interface BoardNode {
|
||||
id: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
type: string;
|
||||
state?: State;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
/** An object representing a link between two nodes on the board. */
|
||||
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
|
||||
startNode: BoardNode;
|
||||
endNode: BoardNode;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
pulsing?: boolean;
|
||||
}
|
||||
|
||||
/** An object representing a label for a node. */
|
||||
export interface NodeLabel {
|
||||
text: string;
|
||||
color?: string;
|
||||
pulsing?: boolean;
|
||||
}
|
||||
|
||||
/** The persistent data for a board. */
|
||||
export type BoardData = {
|
||||
nodes: BoardNode[];
|
||||
selectedNode: number | null;
|
||||
selectedAction: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* An object that configures a {@link NodeType}.
|
||||
*/
|
||||
export interface NodeTypeOptions {
|
||||
/** The title to display for the node. */
|
||||
title: NodeComputable<string>;
|
||||
/** An optional label for the node. */
|
||||
label?: NodeComputable<NodeLabel | null>;
|
||||
/** The size of the node - diameter for circles, width and height for squares. */
|
||||
size: NodeComputable<number>;
|
||||
/** CSS to apply to this node. */
|
||||
style?: NodeComputable<StyleValue>;
|
||||
/** Dictionary of CSS classes to apply to this node. */
|
||||
classes?: NodeComputable<Record<string, boolean>>;
|
||||
/** Whether the node is draggable or not. */
|
||||
draggable?: NodeComputable<boolean>;
|
||||
/** The shape of the node. */
|
||||
shape: NodeComputable<Shape>;
|
||||
/** Whether the node can accept another node being dropped upon it. */
|
||||
canAccept?: NodeComputable<boolean, [BoardNode]>;
|
||||
/** The progress value of the node, from 0 to 1. */
|
||||
progress?: NodeComputable<number>;
|
||||
/** How the progress should be displayed on the node. */
|
||||
progressDisplay?: NodeComputable<ProgressDisplay>;
|
||||
/** The color of the progress indicator. */
|
||||
progressColor?: NodeComputable<string>;
|
||||
/** The fill color of the node. */
|
||||
fillColor?: NodeComputable<string>;
|
||||
/** The outline color of the node. */
|
||||
outlineColor?: NodeComputable<string>;
|
||||
/** The color of the title text. */
|
||||
titleColor?: NodeComputable<string>;
|
||||
/** The list of action options for the node. */
|
||||
actions?: BoardNodeActionOptions[];
|
||||
/** The arc between each action, in radians. */
|
||||
actionDistance?: NodeComputable<number>;
|
||||
/** A function that is called when the node is clicked. */
|
||||
onClick?: (node: BoardNode) => void;
|
||||
/** A function that is called when a node is dropped onto this node. */
|
||||
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
|
||||
/** A function that is called for each node of this type every tick. */
|
||||
update?: (node: BoardNode, diff: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
|
||||
*/
|
||||
export interface BaseNodeType {
|
||||
/** The nodes currently on the board of this type. */
|
||||
nodes: Ref<BoardNode[]>;
|
||||
}
|
||||
|
||||
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
|
||||
export type NodeType<T extends NodeTypeOptions> = Replace<
|
||||
T & BaseNodeType,
|
||||
{
|
||||
title: GetComputableType<T["title"]>;
|
||||
label: GetComputableType<T["label"]>;
|
||||
size: GetComputableTypeWithDefault<T["size"], 50>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
draggable: GetComputableTypeWithDefault<T["draggable"], false>;
|
||||
shape: GetComputableTypeWithDefault<T["shape"], Shape.Circle>;
|
||||
canAccept: GetComputableTypeWithDefault<T["canAccept"], false>;
|
||||
progress: GetComputableType<T["progress"]>;
|
||||
progressDisplay: GetComputableTypeWithDefault<T["progressDisplay"], ProgressDisplay.Fill>;
|
||||
progressColor: GetComputableTypeWithDefault<T["progressColor"], "none">;
|
||||
fillColor: GetComputableType<T["fillColor"]>;
|
||||
outlineColor: GetComputableType<T["outlineColor"]>;
|
||||
titleColor: GetComputableType<T["titleColor"]>;
|
||||
actions?: GenericBoardNodeAction[];
|
||||
actionDistance: GetComputableTypeWithDefault<T["actionDistance"], number>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link NodeType} object. */
|
||||
export type GenericNodeType = Replace<
|
||||
NodeType<NodeTypeOptions>,
|
||||
{
|
||||
size: NodeComputable<number>;
|
||||
draggable: NodeComputable<boolean>;
|
||||
shape: NodeComputable<Shape>;
|
||||
canAccept: NodeComputable<boolean, [BoardNode]>;
|
||||
progressDisplay: NodeComputable<ProgressDisplay>;
|
||||
progressColor: NodeComputable<string>;
|
||||
actionDistance: NodeComputable<number>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An object that configures a {@link BoardNodeAction}.
|
||||
*/
|
||||
export interface BoardNodeActionOptions {
|
||||
/** A unique identifier for the action. */
|
||||
id: string;
|
||||
/** Whether this action should be visible. */
|
||||
visibility?: NodeComputable<Visibility | boolean>;
|
||||
/** The icon to display for the action. */
|
||||
icon: NodeComputable<string>;
|
||||
/** The fill color of the action. */
|
||||
fillColor?: NodeComputable<string>;
|
||||
/** The tooltip text to display for the action. */
|
||||
tooltip: NodeComputable<NodeLabel>;
|
||||
/** The confirmation label that appears under the action. */
|
||||
confirmationLabel?: NodeComputable<NodeLabel>;
|
||||
/** An array of board node links associated with the action. They appear when the action is focused. */
|
||||
links?: NodeComputable<BoardNodeLink[]>;
|
||||
/** A function that is called when the action is clicked. */
|
||||
onClick: (node: BoardNode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
|
||||
*/
|
||||
export interface BaseBoardNodeAction {
|
||||
links?: Ref<BoardNodeLink[]>;
|
||||
}
|
||||
|
||||
/** An object that represents an action that can be taken upon a node. */
|
||||
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
|
||||
T & BaseBoardNodeAction,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
icon: GetComputableType<T["icon"]>;
|
||||
fillColor: GetComputableType<T["fillColor"]>;
|
||||
tooltip: GetComputableType<T["tooltip"]>;
|
||||
confirmationLabel: GetComputableTypeWithDefault<T["confirmationLabel"], NodeLabel>;
|
||||
links: GetComputableType<T["links"]>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link BoardNodeAction} object. */
|
||||
export type GenericBoardNodeAction = Replace<
|
||||
BoardNodeAction<BoardNodeActionOptions>,
|
||||
{
|
||||
visibility: NodeComputable<Visibility | boolean>;
|
||||
confirmationLabel: NodeComputable<NodeLabel>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* An object that configures a {@link Board}.
|
||||
*/
|
||||
export interface BoardOptions {
|
||||
/** Whether this board should be visible. */
|
||||
visibility?: Computable<Visibility | boolean>;
|
||||
/** The height of the board. Defaults to 100% */
|
||||
height?: Computable<string>;
|
||||
/** The width of the board. Defaults to 100% */
|
||||
width?: Computable<string>;
|
||||
/** Dictionary of CSS classes to apply to this feature. */
|
||||
classes?: Computable<Record<string, boolean>>;
|
||||
/** CSS to apply to this feature. */
|
||||
style?: Computable<StyleValue>;
|
||||
/** A function that returns an array of initial board nodes, without IDs. */
|
||||
startNodes: () => Omit<BoardNode, "id">[];
|
||||
/** A dictionary of node types that can appear on the board. */
|
||||
types: Record<string, NodeTypeOptions>;
|
||||
/** The persistent state of the board. */
|
||||
state?: Computable<BoardData>;
|
||||
/** An array of board node links to display. */
|
||||
links?: Computable<BoardNodeLink[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
|
||||
*/
|
||||
export interface BaseBoard {
|
||||
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
|
||||
id: string;
|
||||
/** All the nodes currently on the board. */
|
||||
nodes: Ref<BoardNode[]>;
|
||||
/** The currently selected node, if any. */
|
||||
selectedNode: Ref<BoardNode | null>;
|
||||
/** The currently selected action, if any. */
|
||||
selectedAction: Ref<GenericBoardNodeAction | null>;
|
||||
/** The currently being dragged node, if any. */
|
||||
draggingNode: Ref<BoardNode | null>;
|
||||
/** If dragging a node, the node it's currently being hovered over, if any. */
|
||||
receivingNode: Ref<BoardNode | null>;
|
||||
/** The current mouse position, if over the board. */
|
||||
mousePosition: Ref<{ x: number; y: number } | null>;
|
||||
/** Places a node in the nearest empty space in the given direction with the specified space around it. */
|
||||
placeInAvailableSpace: (node: BoardNode, radius?: number, direction?: Direction) => void;
|
||||
/** A symbol that helps identify features of the same type. */
|
||||
type: typeof BoardType;
|
||||
/** The Vue component used to render this feature. */
|
||||
[Component]: GenericComponent;
|
||||
/** A function to gather the props the vue component requires for this feature. */
|
||||
[GatherProps]: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
|
||||
export type Board<T extends BoardOptions> = Replace<
|
||||
T & BaseBoard,
|
||||
{
|
||||
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
|
||||
types: Record<string, GenericNodeType>;
|
||||
height: GetComputableType<T["height"]>;
|
||||
width: GetComputableType<T["width"]>;
|
||||
classes: GetComputableType<T["classes"]>;
|
||||
style: GetComputableType<T["style"]>;
|
||||
state: GetComputableTypeWithDefault<T["state"], Persistent<BoardData>>;
|
||||
links: GetComputableTypeWithDefault<T["links"], Ref<BoardNodeLink[] | null>>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** A type that matches any valid {@link Board} object. */
|
||||
export type GenericBoard = Replace<
|
||||
Board<BoardOptions>,
|
||||
{
|
||||
visibility: ProcessedComputable<Visibility | boolean>;
|
||||
state: ProcessedComputable<BoardData>;
|
||||
links: ProcessedComputable<BoardNodeLink[] | null>;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lazily creates a board with the given options.
|
||||
* @param optionsFunc Board options.
|
||||
*/
|
||||
export function createBoard<T extends BoardOptions>(
|
||||
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
|
||||
): Board<T> {
|
||||
const state = persistent<BoardData>(
|
||||
{
|
||||
nodes: [],
|
||||
selectedNode: null,
|
||||
selectedAction: null
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
return createLazyProxy(feature => {
|
||||
const board = optionsFunc.call(feature, feature);
|
||||
board.id = getUniqueID("board-");
|
||||
board.type = BoardType;
|
||||
board[Component] = BoardComponent as GenericComponent;
|
||||
|
||||
if (board.state) {
|
||||
deletePersistent(state);
|
||||
processComputable(board as T, "state");
|
||||
} else {
|
||||
state[DefaultValue] = {
|
||||
nodes: board.startNodes().map((n, i) => {
|
||||
(n as BoardNode).id = i;
|
||||
return n as BoardNode;
|
||||
}),
|
||||
selectedNode: null,
|
||||
selectedAction: null
|
||||
};
|
||||
board.state = state;
|
||||
}
|
||||
|
||||
board.nodes = computed(() => unref(processedBoard.state).nodes);
|
||||
board.selectedNode = computed({
|
||||
get() {
|
||||
return (
|
||||
processedBoard.nodes.value.find(
|
||||
node => node.id === unref(processedBoard.state).selectedNode
|
||||
) || null
|
||||
);
|
||||
},
|
||||
set(node) {
|
||||
if (isRef(processedBoard.state)) {
|
||||
processedBoard.state.value = {
|
||||
...processedBoard.state.value,
|
||||
selectedNode: node?.id ?? null
|
||||
};
|
||||
} else {
|
||||
processedBoard.state.selectedNode = node?.id ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
board.selectedAction = computed({
|
||||
get() {
|
||||
const selectedNode = processedBoard.selectedNode.value;
|
||||
if (selectedNode == null) {
|
||||
return null;
|
||||
}
|
||||
const type = processedBoard.types[selectedNode.type];
|
||||
if (type.actions == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
type.actions.find(
|
||||
action => action.id === unref(processedBoard.state).selectedAction
|
||||
) || null
|
||||
);
|
||||
},
|
||||
set(action) {
|
||||
if (isRef(processedBoard.state)) {
|
||||
processedBoard.state.value = {
|
||||
...processedBoard.state.value,
|
||||
selectedAction: action?.id ?? null
|
||||
};
|
||||
} else {
|
||||
processedBoard.state.selectedAction = action?.id ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
board.mousePosition = ref(null);
|
||||
if (board.links) {
|
||||
processComputable(board as T, "links");
|
||||
} else {
|
||||
board.links = computed(() => {
|
||||
if (processedBoard.selectedAction.value == null) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
processedBoard.selectedAction.value.links &&
|
||||
processedBoard.selectedNode.value
|
||||
) {
|
||||
return getNodeProperty(
|
||||
processedBoard.selectedAction.value.links,
|
||||
processedBoard.selectedNode.value
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
board.draggingNode = ref(null);
|
||||
board.receivingNode = ref(null);
|
||||
processComputable(board as T, "visibility");
|
||||
setDefault(board, "visibility", Visibility.Visible);
|
||||
processComputable(board as T, "width");
|
||||
setDefault(board, "width", "100%");
|
||||
processComputable(board as T, "height");
|
||||
setDefault(board, "height", "100%");
|
||||
processComputable(board as T, "classes");
|
||||
processComputable(board as T, "style");
|
||||
|
||||
for (const type in board.types) {
|
||||
const nodeType: NodeTypeOptions & Partial<BaseNodeType> = board.types[type];
|
||||
|
||||
processComputable(nodeType as NodeTypeOptions, "title");
|
||||
processComputable(nodeType as NodeTypeOptions, "label");
|
||||
processComputable(nodeType as NodeTypeOptions, "size");
|
||||
setDefault(nodeType, "size", 50);
|
||||
processComputable(nodeType as NodeTypeOptions, "style");
|
||||
processComputable(nodeType as NodeTypeOptions, "classes");
|
||||
processComputable(nodeType as NodeTypeOptions, "draggable");
|
||||
setDefault(nodeType, "draggable", false);
|
||||
processComputable(nodeType as NodeTypeOptions, "shape");
|
||||
setDefault(nodeType, "shape", Shape.Circle);
|
||||
processComputable(nodeType as NodeTypeOptions, "canAccept");
|
||||
setDefault(nodeType, "canAccept", false);
|
||||
processComputable(nodeType as NodeTypeOptions, "progress");
|
||||
processComputable(nodeType as NodeTypeOptions, "progressDisplay");
|
||||
setDefault(nodeType, "progressDisplay", ProgressDisplay.Fill);
|
||||
processComputable(nodeType as NodeTypeOptions, "progressColor");
|
||||
setDefault(nodeType, "progressColor", "none");
|
||||
processComputable(nodeType as NodeTypeOptions, "fillColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "outlineColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "titleColor");
|
||||
processComputable(nodeType as NodeTypeOptions, "actionDistance");
|
||||
setDefault(nodeType, "actionDistance", Math.PI / 6);
|
||||
nodeType.nodes = computed(() =>
|
||||
unref(processedBoard.state).nodes.filter(node => node.type === type)
|
||||
);
|
||||
setDefault(nodeType, "onClick", function (node: BoardNode) {
|
||||
unref(processedBoard.state).selectedNode = node.id;
|
||||
});
|
||||
|
||||
if (nodeType.actions) {
|
||||
for (const action of nodeType.actions) {
|
||||
processComputable(action, "visibility");
|
||||
setDefault(action, "visibility", Visibility.Visible);
|
||||
processComputable(action, "icon");
|
||||
processComputable(action, "fillColor");
|
||||
processComputable(action, "tooltip");
|
||||
processComputable(action, "confirmationLabel");
|
||||
setDefault(action, "confirmationLabel", { text: "Tap again to confirm" });
|
||||
processComputable(action, "links");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDraggingNode(node: BoardNode | null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
board.draggingNode!.value = node;
|
||||
}
|
||||
function setReceivingNode(node: BoardNode | null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
board.receivingNode!.value = node;
|
||||
}
|
||||
|
||||
board.placeInAvailableSpace = function (
|
||||
node: BoardNode,
|
||||
radius = 100,
|
||||
direction = Direction.Right
|
||||
) {
|
||||
const nodes = processedBoard.nodes.value
|
||||
.slice()
|
||||
.filter(n => {
|
||||
// Exclude self
|
||||
if (n === node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes that aren't within the corridor we'll be moving within
|
||||
if (
|
||||
(direction === Direction.Down || direction === Direction.Up) &&
|
||||
Math.abs(n.position.x - node.position.x) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(direction === Direction.Left || direction === Direction.Right) &&
|
||||
Math.abs(n.position.y - node.position.y) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes in the wrong direction
|
||||
return !(
|
||||
(direction === Direction.Right &&
|
||||
n.position.x < node.position.x - radius) ||
|
||||
(direction === Direction.Left && n.position.x > node.position.x + radius) ||
|
||||
(direction === Direction.Up && n.position.y > node.position.y + radius) ||
|
||||
(direction === Direction.Down && n.position.y < node.position.y - radius)
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
direction === Direction.Right
|
||||
? (a, b) => a.position.x - b.position.x
|
||||
: direction === Direction.Left
|
||||
? (a, b) => b.position.x - a.position.x
|
||||
: direction === Direction.Up
|
||||
? (a, b) => b.position.y - a.position.y
|
||||
: (a, b) => a.position.y - b.position.y
|
||||
);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeToCheck = nodes[i];
|
||||
const distance =
|
||||
direction === Direction.Right || direction === Direction.Left
|
||||
? Math.abs(node.position.x - nodeToCheck.position.x)
|
||||
: Math.abs(node.position.y - nodeToCheck.position.y);
|
||||
|
||||
// If we're too close to this node, move further
|
||||
if (distance < radius) {
|
||||
if (direction === Direction.Right) {
|
||||
node.position.x = nodeToCheck.position.x + radius;
|
||||
} else if (direction === Direction.Left) {
|
||||
node.position.x = nodeToCheck.position.x - radius;
|
||||
} else if (direction === Direction.Up) {
|
||||
node.position.y = nodeToCheck.position.y - radius;
|
||||
} else if (direction === Direction.Down) {
|
||||
node.position.y = nodeToCheck.position.y + radius;
|
||||
}
|
||||
} else if (i > 0 && distance > radius) {
|
||||
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
board[GatherProps] = function (this: GenericBoard) {
|
||||
const {
|
||||
nodes,
|
||||
types,
|
||||
state,
|
||||
visibility,
|
||||
width,
|
||||
height,
|
||||
style,
|
||||
classes,
|
||||
links,
|
||||
selectedAction,
|
||||
selectedNode,
|
||||
mousePosition,
|
||||
draggingNode,
|
||||
receivingNode
|
||||
} = this;
|
||||
return {
|
||||
nodes,
|
||||
types,
|
||||
state,
|
||||
visibility,
|
||||
width,
|
||||
height,
|
||||
style: unref(style),
|
||||
classes,
|
||||
links,
|
||||
selectedAction,
|
||||
selectedNode,
|
||||
mousePosition,
|
||||
draggingNode,
|
||||
receivingNode,
|
||||
setDraggingNode,
|
||||
setReceivingNode
|
||||
};
|
||||
};
|
||||
|
||||
// This is necessary because board.types is different from T and Board
|
||||
const processedBoard = board as unknown as Board<T>;
|
||||
return processedBoard;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of a property for a specified node.
|
||||
* @param property The property to find the value of
|
||||
* @param node The node to get the property of
|
||||
*/
|
||||
export function getNodeProperty<T, S extends unknown[]>(
|
||||
property: NodeComputable<T, S>,
|
||||
node: BoardNode,
|
||||
...args: S
|
||||
): T {
|
||||
return isFunction<T, [BoardNode, ...S], Computable<T>>(property)
|
||||
? property(node, ...args)
|
||||
: unref(property);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get an ID for a node that is guaranteed unique.
|
||||
* @param board The board feature to generate an ID for
|
||||
*/
|
||||
export function getUniqueNodeID(board: GenericBoard): number {
|
||||
let id = 0;
|
||||
board.nodes.value.forEach(node => {
|
||||
if (node.id >= id) {
|
||||
id = node.id + 1;
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
const listeners: Record<string, Unsubscribe | undefined> = {};
|
||||
globalBus.on("addLayer", layer => {
|
||||
const boards: GenericBoard[] = findFeatures(layer, BoardType) as GenericBoard[];
|
||||
listeners[layer.id] = layer.on("postUpdate", diff => {
|
||||
boards.forEach(board => {
|
||||
Object.values(board.types).forEach(type =>
|
||||
type.nodes.value.forEach(node => type.update?.(node, diff))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
globalBus.on("removeLayer", layer => {
|
||||
// unsubscribe from postUpdate
|
||||
listeners[layer.id]?.();
|
||||
listeners[layer.id] = undefined;
|
||||
});
|
317
src/features/boards/board.tsx
Normal file
317
src/features/boards/board.tsx
Normal file
|
@ -0,0 +1,317 @@
|
|||
import Board from "features/boards/Board.vue";
|
||||
import { jsx } from "features/feature";
|
||||
import { globalBus } from "game/events";
|
||||
import type { PanZoom } from "panzoom";
|
||||
import { Direction, isFunction } from "util/common";
|
||||
import type { Computable, ProcessedComputable } from "util/computed";
|
||||
import { convertComputable } from "util/computed";
|
||||
import type { ComponentPublicInstance, Ref } from "vue";
|
||||
import { computed, ref, unref, watchEffect } from "vue";
|
||||
import panZoom from "vue-panzoom";
|
||||
|
||||
globalBus.on("setupVue", app => panZoom.install(app));
|
||||
|
||||
export type NodePosition = { x: number; y: number };
|
||||
|
||||
/**
|
||||
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
|
||||
*/
|
||||
export type NodeComputable<T, R, S extends unknown[] = []> =
|
||||
| Computable<R>
|
||||
| ((node: T, ...args: S) => R);
|
||||
|
||||
/**
|
||||
* Gets the value of a property for a specified node.
|
||||
* @param property The property to find the value of
|
||||
* @param node The node to get the property of
|
||||
*/
|
||||
export function unwrapNodeRef<T, R, S extends unknown[]>(
|
||||
property: NodeComputable<T, R, S>,
|
||||
node: T,
|
||||
...args: S
|
||||
): R {
|
||||
return isFunction<R, [T, ...S], ProcessedComputable<R>>(property)
|
||||
? property(node, ...args)
|
||||
: unref(property);
|
||||
}
|
||||
|
||||
export function setupUniqueIds(nodes: Computable<{ id: number }[]>) {
|
||||
const processedNodes = convertComputable(nodes);
|
||||
return computed(() => Math.max(-1, ...unref(processedNodes).map(node => node.id)) + 1);
|
||||
}
|
||||
|
||||
export function setupSelectable<T>() {
|
||||
const selected = ref<T>();
|
||||
return {
|
||||
select: function (node: T) {
|
||||
selected.value = node;
|
||||
},
|
||||
deselect: function () {
|
||||
selected.value = undefined;
|
||||
},
|
||||
selected
|
||||
};
|
||||
}
|
||||
|
||||
export function setupDraggableNode<T extends NodePosition, S extends NodePosition = T>(options: {
|
||||
board: Ref<ComponentPublicInstance<typeof Board> | undefined>;
|
||||
receivingNodes?: NodeComputable<T, S[]>;
|
||||
dropAreaRadius?: NodeComputable<S, number>;
|
||||
isDraggable?: NodeComputable<T, boolean>;
|
||||
onDrop?: (acceptingNode: S, draggingNode: T) => void;
|
||||
}) {
|
||||
const nodeBeingDragged = ref<T>();
|
||||
const receivingNode = ref<S>();
|
||||
const hasDragged = ref(false);
|
||||
const mousePosition = ref<NodePosition>();
|
||||
const lastMousePosition = ref({ x: 0, y: 0 });
|
||||
const dragDelta = ref({ x: 0, y: 0 });
|
||||
const isDraggable = options.isDraggable ?? true;
|
||||
const receivingNodes = computed(() =>
|
||||
nodeBeingDragged.value == null
|
||||
? []
|
||||
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
unwrapNodeRef(options.receivingNodes ?? [], nodeBeingDragged.value!)
|
||||
);
|
||||
const dropAreaRadius = options.dropAreaRadius ?? 50;
|
||||
|
||||
watchEffect(() => {
|
||||
if (nodeBeingDragged.value != null && !unwrapNodeRef(isDraggable, nodeBeingDragged.value)) {
|
||||
result.endDrag();
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
const node = nodeBeingDragged.value;
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: node.x + dragDelta.value.x,
|
||||
y: node.y + dragDelta.value.y
|
||||
};
|
||||
let smallestDistance = Number.MAX_VALUE;
|
||||
|
||||
receivingNode.value = unref(receivingNodes).reduce((smallest: S | undefined, curr: S) => {
|
||||
if ((curr as S | T) === node) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
const distanceSquared =
|
||||
Math.pow(position.x - curr.x, 2) + Math.pow(position.y - curr.y, 2);
|
||||
const size = unwrapNodeRef(dropAreaRadius, curr);
|
||||
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
|
||||
return smallest;
|
||||
}
|
||||
|
||||
smallestDistance = distanceSquared;
|
||||
return curr;
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
const result = {
|
||||
nodeBeingDragged,
|
||||
receivingNode,
|
||||
hasDragged,
|
||||
mousePosition,
|
||||
lastMousePosition,
|
||||
dragDelta,
|
||||
receivingNodes,
|
||||
startDrag: function (e: MouseEvent | TouchEvent, node?: T) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
dragDelta.value = { x: 0, y: 0 };
|
||||
hasDragged.value = false;
|
||||
|
||||
if (node != null && unwrapNodeRef(isDraggable, node)) {
|
||||
nodeBeingDragged.value = node;
|
||||
}
|
||||
},
|
||||
endDrag: function () {
|
||||
if (nodeBeingDragged.value == null) {
|
||||
return;
|
||||
}
|
||||
if (receivingNode.value == null) {
|
||||
nodeBeingDragged.value.x += Math.round(dragDelta.value.x / 25) * 25;
|
||||
nodeBeingDragged.value.y += Math.round(dragDelta.value.y / 25) * 25;
|
||||
}
|
||||
|
||||
if (receivingNode.value != null) {
|
||||
options.onDrop?.(receivingNode.value, nodeBeingDragged.value);
|
||||
}
|
||||
|
||||
nodeBeingDragged.value = undefined;
|
||||
},
|
||||
drag: function (e: MouseEvent | TouchEvent) {
|
||||
const panZoomInstance = options.board.value?.panZoomInstance as PanZoom | undefined;
|
||||
if (panZoomInstance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y, scale } = panZoomInstance.getTransform();
|
||||
|
||||
let clientX, clientY;
|
||||
if ("touches" in e) {
|
||||
if (e.touches.length === 1) {
|
||||
clientX = e.touches[0].clientX;
|
||||
clientY = e.touches[0].clientY;
|
||||
} else {
|
||||
result.endDrag();
|
||||
mousePosition.value = undefined;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
}
|
||||
|
||||
mousePosition.value = {
|
||||
x: (clientX - x) / scale,
|
||||
y: (clientY - y) / scale
|
||||
};
|
||||
|
||||
dragDelta.value = {
|
||||
x: dragDelta.value.x + (clientX - lastMousePosition.value.x) / scale,
|
||||
y: dragDelta.value.y + (clientY - lastMousePosition.value.y) / scale
|
||||
};
|
||||
lastMousePosition.value = {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
};
|
||||
|
||||
if (Math.abs(dragDelta.value.x) > 10 || Math.abs(dragDelta.value.y) > 10) {
|
||||
hasDragged.value = true;
|
||||
}
|
||||
|
||||
if (nodeBeingDragged.value != null) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function setupActions<T extends NodePosition>(options: {
|
||||
node: Computable<T | undefined>;
|
||||
shouldShowActions?: NodeComputable<T, boolean>;
|
||||
actions: NodeComputable<T, ((position: NodePosition) => JSX.Element)[]>;
|
||||
distance: NodeComputable<T, number>;
|
||||
arcLength?: NodeComputable<T, number>;
|
||||
}) {
|
||||
const node = convertComputable(options.node);
|
||||
return jsx(() => {
|
||||
const currNode = unref(node);
|
||||
if (currNode == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const actions = unwrapNodeRef(options.actions, currNode);
|
||||
const shouldShow = unwrapNodeRef(options.shouldShowActions, currNode) ?? true;
|
||||
if (!shouldShow) {
|
||||
return <>{actions.map(f => f(currNode))}</>;
|
||||
}
|
||||
|
||||
const distance = unwrapNodeRef(options.distance, currNode);
|
||||
const arcLength = unwrapNodeRef(options.arcLength, currNode) ?? Math.PI / 6;
|
||||
const firstAngle = Math.PI / 2 - ((actions.length - 1) / 2) * arcLength;
|
||||
return (
|
||||
<>
|
||||
{actions.map((f, index) =>
|
||||
f({
|
||||
x: currNode.x + Math.cos(firstAngle + index * arcLength) * distance,
|
||||
y: currNode.y + Math.sin(firstAngle + index * arcLength) * distance
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function placeInAvailableSpace<T extends NodePosition>(
|
||||
nodeToPlace: T,
|
||||
nodes: T[],
|
||||
radius = 100,
|
||||
direction = Direction.Right
|
||||
) {
|
||||
nodes = nodes
|
||||
.filter(n => {
|
||||
// Exclude self
|
||||
if (n === nodeToPlace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes that aren't within the corridor we'll be moving within
|
||||
if (
|
||||
(direction === Direction.Down || direction === Direction.Up) &&
|
||||
Math.abs(n.x - nodeToPlace.x) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(direction === Direction.Left || direction === Direction.Right) &&
|
||||
Math.abs(n.y - nodeToPlace.y) > radius
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nodes in the wrong direction
|
||||
return !(
|
||||
(direction === Direction.Right && n.x < nodeToPlace.x - radius) ||
|
||||
(direction === Direction.Left && n.x > nodeToPlace.x + radius) ||
|
||||
(direction === Direction.Up && n.y > nodeToPlace.y + radius) ||
|
||||
(direction === Direction.Down && n.y < nodeToPlace.y - radius)
|
||||
);
|
||||
})
|
||||
.sort(
|
||||
direction === Direction.Right
|
||||
? (a, b) => a.x - b.x
|
||||
: direction === Direction.Left
|
||||
? (a, b) => b.x - a.x
|
||||
: direction === Direction.Up
|
||||
? (a, b) => b.y - a.y
|
||||
: (a, b) => a.y - b.y
|
||||
);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const nodeToCheck = nodes[i];
|
||||
const distance =
|
||||
direction === Direction.Right || direction === Direction.Left
|
||||
? Math.abs(nodeToPlace.x - nodeToCheck.x)
|
||||
: Math.abs(nodeToPlace.y - nodeToCheck.y);
|
||||
|
||||
// If we're too close to this node, move further
|
||||
if (distance < radius) {
|
||||
if (direction === Direction.Right) {
|
||||
nodeToPlace.x = nodeToCheck.x + radius;
|
||||
} else if (direction === Direction.Left) {
|
||||
nodeToPlace.x = nodeToCheck.x - radius;
|
||||
} else if (direction === Direction.Up) {
|
||||
nodeToPlace.y = nodeToCheck.y - radius;
|
||||
} else if (direction === Direction.Down) {
|
||||
nodeToPlace.y = nodeToCheck.y + radius;
|
||||
}
|
||||
} else if (i > 0 && distance > radius) {
|
||||
// If we're further from this node than the radius, then the nodes are past us and we can early exit
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue