Implemented confirmable actions (plus logs and other related features)

This commit is contained in:
thepaperpilot 2021-08-22 22:57:59 -05:00
parent c4db2a51c7
commit 7618ee291a
12 changed files with 351 additions and 36 deletions

View file

@ -2,16 +2,24 @@
<panZoom <panZoom
:style="style" :style="style"
selector="#g1" selector="#g1"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10 }" :options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
ref="stage" ref="stage"
@init="onInit" @init="onInit"
@mousemove="drag" @mousemove="drag"
@mousedown="deselect" @touchmove="drag"
@mousedown="mouseDown"
@touchstart="mouseDown"
@mouseup="() => endDragging(dragging)" @mouseup="() => endDragging(dragging)"
@touchend="() => endDragging(dragging)"
@mouseleave="() => endDragging(dragging)" @mouseleave="() => endDragging(dragging)"
> >
<svg class="stage" width="100%" height="100%"> <svg class="stage" width="100%" height="100%">
<g id="g1"> <g id="g1">
<transition-group name="link" appear>
<g v-for="(link, index) in board.links || []" :key="index">
<BoardLink :link="link" />
</g>
</transition-group>
<BoardNode <BoardNode
v-for="node in nodes" v-for="node in nodes"
:key="node.id" :key="node.id"
@ -127,11 +135,7 @@ export default defineComponent({
onInit(panzoomInstance: any) { onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null); panzoomInstance.setTransformOrigin(null);
}, },
deselect() { mouseDown(e: MouseEvent, nodeID: string | null = null, draggable = false) {
player.layers[this.layer].boards[this.id].selectedNode = null;
player.layers[this.layer].boards[this.id].selectedAction = null;
},
mouseDown(e: MouseEvent, nodeID: string, draggable: boolean) {
if (this.dragging == null) { if (this.dragging == null) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -147,8 +151,10 @@ export default defineComponent({
this.dragging = nodeID; this.dragging = nodeID;
} }
} }
player.layers[this.layer].boards[this.id].selectedNode = null; if (nodeID != null) {
player.layers[this.layer].boards[this.id].selectedAction = null; player.layers[this.layer].boards[this.id].selectedNode = null;
player.layers[this.layer].boards[this.id].selectedAction = null;
}
}, },
drag(e: MouseEvent) { drag(e: MouseEvent) {
const zoom = (this.getZoomLevel as () => number)(); const zoom = (this.getZoomLevel as () => number)();
@ -186,6 +192,9 @@ export default defineComponent({
} }
this.dragging = null; this.dragging = null;
} else if (!this.hasDragged) {
player.layers[this.layer].boards[this.id].selectedNode = null;
player.layers[this.layer].boards[this.id].selectedAction = null;
} }
} }
} }
@ -202,4 +211,9 @@ export default defineComponent({
#g1 { #g1 {
transition-duration: 0s; transition-duration: 0s;
} }
.link-enter-from,
.link-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -0,0 +1,55 @@
<template>
<line
v-bind="link"
class="link"
:class="{ pulsing: link.pulsing }"
:x1="startPosition.x"
:y1="startPosition.y"
:x2="endPosition.x"
:y2="endPosition.y"
/>
</template>
<script lang="ts">
import { Position } from "@/typings/branches";
import { BoardNodeLink } from "@/typings/features/board";
import { defineComponent, PropType } from "vue";
export default defineComponent({
name: "BoardLink",
props: {
link: {
type: Object as PropType<BoardNodeLink>,
required: true
}
},
computed: {
startPosition(): Position {
return this.link.from.position;
},
endPosition(): Position {
return this.link.to.position;
}
}
});
</script>
<style scoped>
.link.pulsing {
animation: pulsing 2s ease-in infinite;
}
@keyframes pulsing {
0% {
opacity: 0.25;
}
50% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}
</style>

View file

@ -6,10 +6,12 @@
> >
<transition name="actions" appear> <transition name="actions" appear>
<g v-if="selected && actions"> <g v-if="selected && actions">
<!-- TODO move to separate file -->
<g <g
v-for="(action, index) in actions" v-for="(action, index) in actions"
:key="action.id" :key="action.id"
class="action" class="action"
:class="{ selected: selectedAction === action }"
:transform=" :transform="
`translate( `translate(
${(-size - 30) * ${(-size - 30) *
@ -18,10 +20,23 @@
Math.cos(((actions.length - 1) / 2 - index) * actionDistance)} Math.cos(((actions.length - 1) / 2 - index) * actionDistance)}
)` )`
" "
@click="performAction(action)" @mousedown="e => performAction(e, action)"
> >
<circle :fill="fillColor" r="20" /> <circle
<text :fill="titleColor" class="material-icons">{{ action.icon }}</text> :fill="
action.fillColor
? typeof action.fillColor === 'function'
? action.fillColor(node)
: action.fillColor
: fillColor
"
r="20"
:stroke-width="selectedAction === action ? 4 : 0"
:stroke="outlineColor"
/>
<text :fill="titleColor" class="material-icons">{{
typeof action.icon === "function" ? action.icon(node) : action.icon
}}</text>
</g> </g>
</g> </g>
</transition> </transition>
@ -31,7 +46,9 @@
@mouseenter="mouseEnter" @mouseenter="mouseEnter"
@mouseleave="mouseLeave" @mouseleave="mouseLeave"
@mousedown="mouseDown" @mousedown="mouseDown"
@touchstart="mouseDown"
@mouseup="mouseUp" @mouseup="mouseUp"
@touchend="mouseUp"
> >
<circle <circle
v-if="canAccept" v-if="canAccept"
@ -67,7 +84,9 @@
@mouseenter="mouseEnter" @mouseenter="mouseEnter"
@mouseleave="mouseLeave" @mouseleave="mouseLeave"
@mousedown="mouseDown" @mousedown="mouseDown"
@touchstart="mouseDown"
@mouseup="mouseUp" @mouseup="mouseUp"
@touchend="mouseUp"
> >
<rect <rect
v-if="canAccept" v-if="canAccept"
@ -116,6 +135,27 @@
</g> </g>
<text :fill="titleColor" class="node-title">{{ title }}</text> <text :fill="titleColor" class="node-title">{{ title }}</text>
<transition name="fade" appear>
<text
v-if="label"
:fill="label.color || titleColor"
class="node-title"
:class="{ pulsing: label.pulsing }"
:y="-size - 20"
>{{ label.text }}</text
>
</transition>
<transition name="fade" appear>
<text
:fill="titleColor"
class="node-title"
:y="size + 75"
v-if="selected && selectedAction"
>Tap again to confirm</text
>
</transition>
</g> </g>
</template> </template>
@ -124,14 +164,12 @@ import themes from "@/data/themes";
import { ProgressDisplay, Shape } from "@/game/enums"; import { ProgressDisplay, Shape } from "@/game/enums";
import { layers } from "@/game/layers"; import { layers } from "@/game/layers";
import player from "@/game/player"; import player from "@/game/player";
import { BoardNode, BoardNodeAction, NodeType } from "@/typings/features/board"; import { BoardNode, BoardNodeAction, NodeLabel, NodeType } from "@/typings/features/board";
import { getNodeTypeProperty } from "@/util/features"; import { getNodeTypeProperty } from "@/util/features";
import { InjectLayerMixin } from "@/util/vue";
import { defineComponent, PropType } from "vue"; import { defineComponent, PropType } from "vue";
export default defineComponent({ export default defineComponent({
name: "BoardNode", name: "BoardNode",
mixins: [InjectLayerMixin],
data() { data() {
return { return {
ProgressDisplay, ProgressDisplay,
@ -174,6 +212,9 @@ export default defineComponent({
selected() { selected() {
return this.board.selectedNode?.id === this.node.id; return this.board.selectedNode?.id === this.node.id;
}, },
selectedAction() {
return this.board.selectedAction;
},
actions(): BoardNodeAction[] | null | undefined { actions(): BoardNodeAction[] | null | undefined {
return getNodeTypeProperty(this.nodeType, this.node, "actions"); return getNodeTypeProperty(this.nodeType, this.node, "actions");
}, },
@ -203,6 +244,9 @@ export default defineComponent({
title(): string { title(): string {
return getNodeTypeProperty(this.nodeType, this.node, "title"); return getNodeTypeProperty(this.nodeType, this.node, "title");
}, },
label(): NodeLabel | null | undefined {
return getNodeTypeProperty(this.nodeType, this.node, "label");
},
progress(): number { progress(): number {
return getNodeTypeProperty(this.nodeType, this.node, "progress") || 0; return getNodeTypeProperty(this.nodeType, this.node, "progress") || 0;
}, },
@ -263,8 +307,14 @@ export default defineComponent({
mouseLeave() { mouseLeave() {
this.hovering = false; this.hovering = false;
}, },
performAction(action: BoardNodeAction) { performAction(e: MouseEvent, action: BoardNodeAction) {
action.onClick(this.node); action.onClick(this.node);
// If the onClick function made this action selected,
// don't propagate the event (which will deselect everything)
if (this.board.selectedAction === action) {
e.preventDefault();
e.stopPropagation();
}
} }
}, },
watch: { watch: {
@ -295,11 +345,13 @@ export default defineComponent({
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.action:hover circle { .action:hover circle,
.action.selected circle {
r: 25; r: 25;
} }
.action:hover text { .action:hover text,
.action.selected text {
font-size: 187.5%; /* 150% * 1.25 */ font-size: 187.5%; /* 150% * 1.25 */
} }
@ -307,6 +359,29 @@ export default defineComponent({
text-anchor: middle; text-anchor: middle;
dominant-baseline: central; dominant-baseline: central;
} }
.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>
<style> <style>

View file

@ -37,11 +37,11 @@ export default defineComponent({
} }
return this.options.stroke!; return this.options.stroke!;
}, },
strokeWidth(): string { strokeWidth(): number | string {
if (typeof this.options === "string" || !("stroke-width" in this.options)) { if (typeof this.options === "string" || !("strokeWidth" in this.options)) {
return "15px"; return "15";
} }
return this.options["stroke-width"]!; return this.options["strokeWidth"]!;
}, },
startPosition(): Position { startPosition(): Position {
const position = { x: this.startNode.x || 0, y: this.startNode.y || 0 }; const position = { x: this.startNode.x || 0, y: this.startNode.y || 0 };

View file

@ -59,8 +59,16 @@ export default defineComponent(function Main() {
camelToTitle((node.data as ActionNodeData).actionType) camelToTitle((node.data as ActionNodeData).actionType)
); );
body.value = coerceComponent( body.value = coerceComponent(
"<div><div>" + "<div><div class='entry'>" +
(node.data as ActionNodeData).log.join("</div><div>") + (node.data as ActionNodeData).log
.map(log => {
let display = log.description;
if (log.effectDescription) {
display += `<div style="font-style: italic;">${log.effectDescription}</div>`;
}
return display;
})
.join("</div><div class='entry'>") +
"</div></div>" "</div></div>"
); );
break; break;

View file

@ -1,8 +1,10 @@
import { ProgressDisplay, Shape } from "@/game/enums"; import { ProgressDisplay, Shape } from "@/game/enums";
import { layers } from "@/game/layers";
import player from "@/game/player"; import player from "@/game/player";
import Decimal, { DecimalSource } from "@/lib/break_eternity"; import Decimal, { DecimalSource } from "@/lib/break_eternity";
import { RawLayer } from "@/typings/layer"; import { RawLayer } from "@/typings/layer";
import { camelToTitle } from "@/util/common"; import { camelToTitle } from "@/util/common";
import { getUniqueNodeID } from "@/util/features";
import themes from "../themes"; import themes from "../themes";
import Main from "./Main.vue"; import Main from "./Main.vue";
@ -19,9 +21,64 @@ export type ItemNodeData = {
export type ActionNodeData = { export type ActionNodeData = {
actionType: string; actionType: string;
log: string[]; log: LogEntry[];
}; };
export type LogEntry = {
description: string;
effectDescription?: string;
};
export type WeightedEvent = {
event: () => LogEntry;
weight: number;
};
const redditEvents = [
{
event: () => ({ description: "You blink and half an hour has passed before you know it." }),
weight: 1
},
{
event: () => {
const id = getUniqueNodeID(layers.main.boards!.data.main);
player.layers.main.boards.main.nodes.push({
id,
position: { x: 0, y: 150 }, // TODO function to get nearest unoccupied space
type: "item",
data: {
itemType: "speed",
amount: new Decimal(15 * 60)
}
});
return {
description: "You found some funny memes and actually feel a bit refreshed.",
effectDescription: `Added <span style="color: #0FF;">Speed</span> node`
};
},
weight: 0.5
}
];
function getRandomEvent(events: WeightedEvent[]): LogEntry | null {
if (events.length === 0) {
return null;
}
const totalWeight = events.reduce((acc, curr) => acc + curr.weight, 0);
const random = Math.random() * totalWeight;
let weight = 0;
for (const outcome of events) {
weight += outcome.weight;
if (random <= weight) {
return outcome.event();
}
}
// Should never reach here
return null;
}
export default { export default {
id: "main", id: "main",
display: Main, display: Main,
@ -80,6 +137,21 @@ export default {
title(node) { title(node) {
return (node.data as ResourceNodeData).resourceType; return (node.data as ResourceNodeData).resourceType;
}, },
label(node) {
if (player.layers[this.layer].boards[this.id].selectedAction == null) {
return null;
}
const action = player.layers[this.layer].boards[this.id].selectedAction;
switch (action) {
default:
return null;
case "reddit":
if ((node.data as ResourceNodeData).resourceType === "time") {
return { text: "30m", color: "red", pulsing: true };
}
return null;
}
},
draggable: true, draggable: true,
progress(node) { progress(node) {
const data = node.data as ResourceNodeData; const data = node.data as ResourceNodeData;
@ -109,6 +181,10 @@ export default {
otherNode otherNode
); );
player.layers[this.layer].boards[this.id].nodes.splice(index, 1); player.layers[this.layer].boards[this.id].nodes.splice(index, 1);
(node.data as ResourceNodeData).amount = Decimal.add(
(node.data as ResourceNodeData).amount,
(otherNode.data as ItemNodeData).amount
);
} }
}, },
item: { item: {
@ -134,6 +210,9 @@ export default {
{ {
id: "info", id: "info",
icon: "history_edu", icon: "history_edu",
fillColor() {
return themes[player.theme].variables["--separator"];
},
tooltip: "Log", tooltip: "Log",
onClick(node) { onClick(node) {
player.layers.main.openNode = node.id; player.layers.main.openNode = node.id;
@ -145,7 +224,44 @@ export default {
icon: "reddit", icon: "reddit",
tooltip: "Browse Reddit", tooltip: "Browse Reddit",
onClick(node) { onClick(node) {
// TODO if (player.layers.main.boards.main.selectedAction === this.id) {
const timeNode = player.layers.main.boards.main.nodes.find(
node =>
node.type === "resource" &&
(node.data as ResourceNodeData).resourceType ===
"time"
);
if (timeNode) {
(timeNode.data as ResourceNodeData).amount = Decimal.sub(
(timeNode.data as ResourceNodeData).amount,
30 * 60
);
player.layers.main.boards.main.selectedAction = null;
(node.data as ActionNodeData).log.push(
getRandomEvent(redditEvents)!
);
}
} else {
player.layers.main.boards.main.selectedAction = this.id;
}
},
links(node) {
return [
{
// TODO this is ridiculous and needs some utility
// function to shrink it down
from: player.layers.main.boards.main.nodes.find(
node =>
node.type === "resource" &&
(node.data as ResourceNodeData).resourceType ===
"time"
),
to: node,
stroke: "red",
"stroke-width": 4,
pulsing: true
}
];
} }
} }
] ]

View file

@ -1,6 +1,7 @@
import { RawLayer } from "@/typings/layer"; import { RawLayer } from "@/typings/layer";
import { PlayerData } from "@/typings/player"; import { PlayerData } from "@/typings/player";
import Decimal from "@/util/bignum"; import Decimal from "@/util/bignum";
import { hardReset } from "@/util/save";
import { computed } from "vue"; import { computed } from "vue";
import main from "./layers/main"; import main from "./layers/main";

View file

@ -450,10 +450,26 @@ export function addLayer(layer: RawLayer, player?: Partial<PlayerData>): void {
if (nodeType.actions === null) { if (nodeType.actions === null) {
return null; return null;
} }
if (typeof nodeType.actions === "function") { const actions =
return nodeType.actions(this.selectedNode); typeof nodeType.actions === "function"
? nodeType.actions(this.selectedNode)
: nodeType.actions;
return actions?.find(
action =>
action.id === playerProxy.layers[this.layer].boards[this.id].selectedAction
);
});
setDefault(layer.boards.data[id], "links", function() {
if (this.selectedAction == null) {
return null;
} }
return nodeType.actions; if (this.selectedAction.links) {
if (typeof this.selectedAction.links === "function") {
return this.selectedAction.links(this.selectedNode);
}
return this.selectedAction.links;
}
return null;
}); });
for (const nodeType in layer.boards.data[id].types) { for (const nodeType in layer.boards.data[id].types) {
layer.boards.data[id].types[nodeType].layer = layer.id; layer.boards.data[id].types[nodeType].layer = layer.id;

View file

@ -17,9 +17,10 @@ export interface BranchOptions {
target?: string; target?: string;
featureType?: string; featureType?: string;
stroke?: string; stroke?: string;
"stroke-width"?: string; strokeWidth?: number | string;
startOffset?: Position; startOffset?: Position;
endOffset?: Position; endOffset?: Position;
[key: string]: any;
} }
export interface Position { export interface Position {

View file

@ -4,7 +4,7 @@ import { State } from "../state";
import { Feature, RawFeature } from "./feature"; import { Feature, RawFeature } from "./feature";
export interface BoardNode { export interface BoardNode {
id: string; id: number;
position: { position: {
x: number; x: number;
y: number; y: number;
@ -28,6 +28,7 @@ export interface Board extends Feature {
nodes: BoardNode[]; nodes: BoardNode[];
selectedNode: BoardNode | null; selectedNode: BoardNode | null;
selectedAction: BoardNodeAction | null; selectedAction: BoardNodeAction | null;
links: BoardNodeLink[] | null;
} }
export type RawBoard = Omit<RawFeature<Board>, "types" | "startNodes"> & { export type RawBoard = Omit<RawFeature<Board>, "types" | "startNodes"> & {
@ -37,7 +38,8 @@ export type RawBoard = Omit<RawFeature<Board>, "types" | "startNodes"> & {
export interface NodeType extends Feature { export interface NodeType extends Feature {
title: string | ((node: BoardNode) => string); title: string | ((node: BoardNode) => string);
size: number | ((node: BoardNode) => number); label?: NodeLabel | null | ((node: BoardNode) => NodeLabel | null);
size: number | string | ((node: BoardNode) => number | string);
draggable: boolean | ((node: BoardNode) => boolean); draggable: boolean | ((node: BoardNode) => boolean);
shape: Shape | ((node: BoardNode) => Shape); shape: Shape | ((node: BoardNode) => Shape);
canAccept: boolean | ((node: BoardNode, otherNode: BoardNode) => boolean); canAccept: boolean | ((node: BoardNode, otherNode: BoardNode) => boolean);
@ -57,7 +59,24 @@ export interface NodeType extends Feature {
export interface BoardNodeAction { export interface BoardNodeAction {
id: string; id: string;
icon: string; icon: string | ((node: BoardNode) => string);
tooltip: string; fillColor?: string | ((node: BoardNode) => string);
tooltip: string | ((node: BoardNode) => string);
onClick: (node: BoardNode) => void; onClick: (node: BoardNode) => void;
links?: BoardNodeLink[] | ((node: BoardNode) => BoardNodeLink[]);
}
export interface BoardNodeLink {
from: BoardNode;
to: BoardNode;
stroke: string;
strokeWidth: number | string;
pulsing?: boolean;
[key: string]: any;
}
export interface NodeLabel {
text: string;
color?: string;
pulsing?: boolean;
} }

View file

@ -1,5 +1,5 @@
import { layers } from "@/game/layers"; import { layers } from "@/game/layers";
import { NodeType, BoardNode } from "@/typings/features/board"; import { NodeType, BoardNode, Board } from "@/typings/features/board";
import { GridCell } from "@/typings/features/grid"; import { GridCell } from "@/typings/features/grid";
import { State } from "@/typings/state"; import { State } from "@/typings/state";
import Decimal, { DecimalSource } from "@/util/bignum"; import Decimal, { DecimalSource } from "@/util/bignum";
@ -107,3 +107,13 @@ export function getNodeTypeProperty<T, S extends NodeType, R extends keyof S>(
? (nodeType[property] as (node: BoardNode) => T)(node) ? (nodeType[property] as (node: BoardNode) => T)(node)
: (nodeType[property] as T); : (nodeType[property] as T);
} }
export function getUniqueNodeID(board: Board): number {
let id = 0;
board.nodes.forEach(node => {
if (node.id >= id) {
id = node.id + 1;
}
});
return id;
}

View file

@ -187,7 +187,7 @@ window.onbeforeunload = () => {
} }
}; };
window.save = save; window.save = save;
window.hardReset = async () => { export const hardReset = window.hardReset = async () => {
await loadSave(newSave()); await loadSave(newSave());
const modData = JSON.parse(decodeURIComponent(escape(atob(localStorage.getItem(modInfo.id)!)))); const modData = JSON.parse(decodeURIComponent(escape(atob(localStorage.getItem(modInfo.id)!))));
modData.active = player.id; modData.active = player.id;