WIP on rewriting board

This commit is contained in:
thepaperpilot 2024-02-28 23:19:11 -06:00
parent 6ba25f9abd
commit 1cbe97251c
11 changed files with 820 additions and 1489 deletions

View file

@ -7,3 +7,12 @@
.modifier-toggle.collapsed { .modifier-toggle.collapsed {
transform: translate(-5px, -5px) rotate(-90deg); 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));
}

View file

@ -1,92 +1,371 @@
import Node from "components/Node.vue"; import Board from "features/boards/Board.vue";
import Spacer from "components/layout/Spacer.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 { 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 type { BaseLayer, GenericLayer } from "game/layers";
import { createLayer } from "game/layers"; import { createLayer } from "game/layers";
import { persistent } from "game/persistence";
import type { Player } from "game/player"; import type { Player } from "game/player";
import player from "game/player"; import { ComponentPublicInstance, computed, ref, watch } from "vue";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatTime } from "util/bignum";
import { render } from "util/vue";
import { computed, toRaw } from "vue";
import prestige from "./layers/prestige"; 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 * @hidden
*/ */
export const main = createLayer("main", function (this: BaseLayer) { export const main = createLayer("main", function (this: BaseLayer) {
const points = createResource<DecimalSource>(10); const board = ref<ComponentPublicInstance<typeof Board>>();
const best = trackBest(points);
const total = trackTotal(points);
const pointGain = computed(() => { const { select, deselect, selected } = setupSelectable<NodeTypes>();
// eslint-disable-next-line prefer-const const {
let gain = new Decimal(1); select: selectAction,
return gain; deselect: deselectAction,
}); selected: selectedAction
globalBus.on("update", diff => { } = setupSelectable<number>();
points.value = Decimal.add(points.value, Decimal.times(pointGain.value, diff));
});
const oomps = trackOOMPS(points, pointGain);
const tree = createTree(() => ({ watch(selected, selected => {
nodes: [[prestige.treeNode]], if (selected == null) {
branches: [], deselectAction();
onReset() { }
points.value = toRaw(this.resettingNode.value) === toRaw(prestige.treeNode) ? 0 : 10; });
best.value = points.value;
total.value = points.value; 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 distance: 100
})) as GenericTree; });
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 { return {
name: "Tree", name: "Tree",
links: tree.links,
display: jsx(() => ( display: jsx(() => (
<> <>
{player.devSpeed === 0 ? ( <Board
<div> onDrag={drag}
Game Paused onMouseDown={deselect}
<Node id="paused" /> onMouseUp={endDrag}
</div> onMouseLeave={endDrag}
) : null} ref={board}
{player.devSpeed != null && player.devSpeed !== 0 && player.devSpeed !== 1 ? ( >
<div> <SVGNode>{links()}</SVGNode>
Dev Speed: {format(player.devSpeed)}x {nodes.value.filter(filterNodes).map(renderNode)}
<Node id="devspeed" /> <SVGNode>
</div> {aActions()}
) : null} {bActions()}
{player.offlineTime != null && player.offlineTime !== 0 ? ( </SVGNode>
<div> {renderNode(selected.value)}
Offline Time: {formatTime(player.offlineTime)} {renderNode(nodeBeingDragged.value)}
<Node id="offline" /> </Board>
</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)}
</> </>
)), )),
points, boardNodes: nodes
best, // cNode
total,
oomps,
tree
}; };
}); });

View file

@ -1,278 +1,75 @@
<template> <template>
<panZoom <panZoom
v-if="isVisible(visibility)" selector=".stage"
:style="[
{
width,
height
},
style
]"
:class="classes"
selector=".g1"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }" :options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
ref="stage" ref="stage"
@init="onInit" @init="onInit"
@mousemove="drag" @mousemove="(e: MouseEvent) => emit('drag', e)"
@touchmove="drag" @touchmove="(e: TouchEvent) => emit('drag', e)"
@mousedown="(e: MouseEvent) => mouseDown(e)" @mouseleave="(e: MouseEvent) => emit('mouseLeave', e)"
@touchstart="(e: TouchEvent) => mouseDown(e)" @mouseup="(e: MouseEvent) => emit('mouseUp', e)"
@mouseup="() => endDragging(unref(draggingNode))" @touchend.passive="(e: TouchEvent) => emit('mouseUp', e)"
@touchend.passive="() => endDragging(unref(draggingNode))"
@mouseleave="() => endDragging(unref(draggingNode), true)"
> >
<svg class="stage" width="100%" height="100%"> <div
<g class="g1"> class="event-listener"
<transition-group name="link" appear> @mousedown="(e: MouseEvent) => emit('mouseDown', e)"
<g @touchstart="(e: TouchEvent) => emit('mouseDown', e)"
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
"
/> />
</g> <div class="stage">
</transition-group> <slot />
<transition-group name="grow" :duration="500" appear> </div>
<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>
</panZoom> </panZoom>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { import type { PanZoom } from "panzoom";
BoardData, import type { ComponentPublicInstance } from "vue";
BoardNode, import { computed, ref } from "vue";
BoardNodeLink, // Required to make sure panzoom component gets registered:
GenericBoardNodeAction, import "features/boards/board";
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";
const _props = defineProps<{ defineExpose({
nodes: Ref<BoardNode[]>; panZoomInstance: computed(() => stage.value?.panZoomInstance)
types: Record<string, GenericNodeType>; });
state: Ref<BoardData>; const emit = defineEmits<{
visibility: ProcessedComputable<Visibility | boolean>; (event: "drag", e: MouseEvent | TouchEvent): void;
width?: ProcessedComputable<string>; (event: "mouseDown", e: MouseEvent | TouchEvent): void;
height?: ProcessedComputable<string>; (event: "mouseUp", e: MouseEvent | TouchEvent): void;
style?: ProcessedComputable<StyleValue>; (event: "mouseLeave", e: MouseEvent | TouchEvent): void;
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;
}>(); }>();
const props = toRefs(_props);
const lastMousePosition = ref({ x: 0, y: 0 }); const stage = ref<{ panZoomInstance: PanZoom } & ComponentPublicInstance<HTMLElement>>();
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 sortedNodes = computed(() => { function onInit(panzoomInstance: PanZoom) {
const nodes = props.nodes.value.slice(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment
if (props.selectedNode.value) { // @ts-ignore
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) {
panzoomInstance.setTransformOrigin(null); panzoomInstance.setTransformOrigin(null);
panzoomInstance.moveTo(stage.value.$el.clientWidth / 2, stage.value.$el.clientHeight / 2); panzoomInstance.moveTo(0, 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 };
}
} }
</script> </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> <style>
.vue-pan-zoom-item {
overflow: hidden;
}
.vue-pan-zoom-scene { .vue-pan-zoom-scene {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -283,12 +80,14 @@ function clickAction(node: BoardNode, actionId: string) {
cursor: grabbing; cursor: grabbing;
} }
.g1 { .stage > * {
transition-duration: 0s; pointer-events: initial;
} }
.link-enter-from, /* "Only" child (excluding resize listener) */
.link-leave-to { .layer-tab > .vue-pan-zoom-item:first-child:nth-last-child(2) {
opacity: 0; width: calc(100% + 20px);
height: calc(100% + 100px);
margin: -50px -10px;
} }
</style> </style>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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;
});

View 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;
}
}
}