@rohal12/spindle 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/App.tsx +7 -1
- package/src/components/TriggerDialogHost.tsx +42 -0
- package/src/components/macros/Button.tsx +25 -10
- package/src/components/macros/Dialog.tsx +6 -12
- package/src/components/macros/MacroLink.tsx +24 -36
- package/src/components/macros/Watch.tsx +75 -0
- package/src/index.tsx +16 -0
- package/src/macros/register-builtins.ts +1 -0
- package/src/store.ts +2 -0
- package/src/story-api.ts +18 -0
- package/src/story-init.ts +15 -48
- package/src/triggers.ts +161 -0
package/package.json
CHANGED
package/src/components/App.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'preact/hooks';
|
|
2
2
|
import { useStoryStore } from '../store';
|
|
3
3
|
import { StoryInterface } from './StoryInterface';
|
|
4
|
+
import { TriggerDialogHost } from './TriggerDialogHost';
|
|
4
5
|
|
|
5
6
|
export function App() {
|
|
6
7
|
const storyData = useStoryStore((s) => s.storyData);
|
|
@@ -24,5 +25,10 @@ export function App() {
|
|
|
24
25
|
return <div class="loading">Loading...</div>;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
return
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<StoryInterface />
|
|
31
|
+
<TriggerDialogHost />
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
28
34
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
2
|
+
import { PassageDialog } from './PassageDialog';
|
|
3
|
+
import {
|
|
4
|
+
subscribeTriggerDialogs,
|
|
5
|
+
shiftDialogQueue,
|
|
6
|
+
dialogQueueLength,
|
|
7
|
+
} from '../triggers';
|
|
8
|
+
|
|
9
|
+
export function TriggerDialogHost() {
|
|
10
|
+
const [current, setCurrent] = useState<string | null>(null);
|
|
11
|
+
|
|
12
|
+
const advance = useCallback(() => {
|
|
13
|
+
const next = shiftDialogQueue();
|
|
14
|
+
setCurrent(next ?? null);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
return subscribeTriggerDialogs(() => {
|
|
19
|
+
setCurrent((prev) => {
|
|
20
|
+
if (prev !== null) return prev; // already showing one
|
|
21
|
+
return shiftDialogQueue() ?? null;
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const handleClose = useCallback(() => {
|
|
27
|
+
setCurrent(null);
|
|
28
|
+
// Show next queued dialog after a tick
|
|
29
|
+
if (dialogQueueLength() > 0) {
|
|
30
|
+
requestAnimationFrame(advance);
|
|
31
|
+
}
|
|
32
|
+
}, [advance]);
|
|
33
|
+
|
|
34
|
+
if (!current) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<PassageDialog
|
|
38
|
+
passageName={current}
|
|
39
|
+
onClose={handleClose}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -1,25 +1,40 @@
|
|
|
1
|
+
import { h, render } from 'preact';
|
|
1
2
|
import { defineMacro } from '../../define-macro';
|
|
3
|
+
import {
|
|
4
|
+
renderNodes,
|
|
5
|
+
LocalsUpdateContext,
|
|
6
|
+
LocalsValuesContext,
|
|
7
|
+
} from '../../markup/render';
|
|
2
8
|
|
|
3
9
|
defineMacro({
|
|
4
10
|
name: 'button',
|
|
5
11
|
interpolate: true,
|
|
6
12
|
render({ rawArgs, children = [] }, ctx) {
|
|
13
|
+
const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
|
|
14
|
+
|
|
7
15
|
const handleClick = () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
// Render children into a detached DOM node — all macro side effects
|
|
17
|
+
// ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
|
|
18
|
+
// Wrap with locals context so @local variables from for-loops are available.
|
|
19
|
+
const container = document.createElement('div');
|
|
20
|
+
const vnode = h(
|
|
21
|
+
LocalsUpdateContext.Provider,
|
|
22
|
+
{ value: { update: ctx.update, getValues: ctx.getValues } },
|
|
23
|
+
h(
|
|
24
|
+
LocalsValuesContext.Provider,
|
|
25
|
+
{ value: ctx.getValues() },
|
|
26
|
+
renderNodes(children),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
render(vnode, container);
|
|
30
|
+
render(null, container);
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
ctx.useAction({
|
|
19
34
|
type: 'button',
|
|
20
35
|
key: rawArgs,
|
|
21
36
|
authorId: ctx.id,
|
|
22
|
-
label
|
|
37
|
+
label,
|
|
23
38
|
perform: handleClick,
|
|
24
39
|
});
|
|
25
40
|
|
|
@@ -29,7 +44,7 @@ defineMacro({
|
|
|
29
44
|
class={ctx.cls}
|
|
30
45
|
onClick={handleClick}
|
|
31
46
|
>
|
|
32
|
-
{
|
|
47
|
+
{label}
|
|
33
48
|
</button>
|
|
34
49
|
);
|
|
35
50
|
},
|
|
@@ -3,21 +3,15 @@ import { PassageDialog } from '../PassageDialog';
|
|
|
3
3
|
|
|
4
4
|
defineMacro({
|
|
5
5
|
name: 'dialog',
|
|
6
|
-
|
|
6
|
+
interpolate: true,
|
|
7
7
|
render({ rawArgs, children = [] }, ctx) {
|
|
8
8
|
const [open, setOpen] = ctx.hooks.useState(false);
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const label = children.length
|
|
19
|
-
? ctx.renderInlineNodes(children)
|
|
20
|
-
: passageName;
|
|
10
|
+
const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
|
|
11
|
+
const passageName = ctx
|
|
12
|
+
.collectText(children)
|
|
13
|
+
.trim()
|
|
14
|
+
.replace(/^["']|["']$/g, '');
|
|
21
15
|
|
|
22
16
|
return (
|
|
23
17
|
<>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { h, render } from 'preact';
|
|
1
2
|
import { useStoryStore } from '../../store';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
renderNodes,
|
|
5
|
+
LocalsUpdateContext,
|
|
6
|
+
LocalsValuesContext,
|
|
7
|
+
} from '../../markup/render';
|
|
6
8
|
import { defineMacro } from '../../define-macro';
|
|
7
9
|
|
|
8
10
|
function parseArgs(rawArgs: string): {
|
|
@@ -26,37 +28,23 @@ function parseArgs(rawArgs: string): {
|
|
|
26
28
|
return { display: rawArgs.trim(), passage: null };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
children: ASTNode[],
|
|
34
|
-
mergedLocals: Record<string, unknown>,
|
|
35
|
-
scopeUpdate: (key: string, value: unknown) => void,
|
|
31
|
+
function renderChildrenDetached(
|
|
32
|
+
children: import('../../markup/ast').ASTNode[],
|
|
33
|
+
getValues: () => Record<string, unknown>,
|
|
34
|
+
update: (key: string, value: unknown) => void,
|
|
36
35
|
) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const code = collectText(node.children);
|
|
50
|
-
try {
|
|
51
|
-
executeMutation(code, mergedLocals, scopeUpdate);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.error(
|
|
54
|
-
`spindle: Error in {link} child {do}${currentSourceLocation()}:`,
|
|
55
|
-
err,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
36
|
+
const container = document.createElement('div');
|
|
37
|
+
const vnode = h(
|
|
38
|
+
LocalsUpdateContext.Provider,
|
|
39
|
+
{ value: { update, getValues } },
|
|
40
|
+
h(
|
|
41
|
+
LocalsValuesContext.Provider,
|
|
42
|
+
{ value: getValues() },
|
|
43
|
+
renderNodes(children),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
render(vnode, container);
|
|
47
|
+
render(null, container);
|
|
60
48
|
}
|
|
61
49
|
|
|
62
50
|
defineMacro({
|
|
@@ -67,7 +55,7 @@ defineMacro({
|
|
|
67
55
|
|
|
68
56
|
const handleClick = (e: Event) => {
|
|
69
57
|
e.preventDefault();
|
|
70
|
-
|
|
58
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
71
59
|
if (passage) {
|
|
72
60
|
useStoryStore.getState().navigate(passage);
|
|
73
61
|
}
|
|
@@ -80,7 +68,7 @@ defineMacro({
|
|
|
80
68
|
label: display,
|
|
81
69
|
target: passage ?? undefined,
|
|
82
70
|
perform: () => {
|
|
83
|
-
|
|
71
|
+
renderChildrenDetached(children, ctx.getValues, ctx.update);
|
|
84
72
|
if (passage) {
|
|
85
73
|
useStoryStore.getState().navigate(passage);
|
|
86
74
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { defineMacro } from '../../define-macro';
|
|
2
|
+
import { addTrigger, removeTrigger } from '../../triggers';
|
|
3
|
+
import type { WatchOptions } from '../../triggers';
|
|
4
|
+
|
|
5
|
+
defineMacro({
|
|
6
|
+
name: 'watch',
|
|
7
|
+
render(props, ctx) {
|
|
8
|
+
const { hooks } = ctx;
|
|
9
|
+
|
|
10
|
+
// Parse: condition string + keyword options
|
|
11
|
+
const raw = props.rawArgs.trim();
|
|
12
|
+
// First quoted string is the condition
|
|
13
|
+
const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
|
|
14
|
+
if (!condMatch) return null;
|
|
15
|
+
|
|
16
|
+
const condition = condMatch[2]!;
|
|
17
|
+
const rest = raw.slice(condMatch[0].length);
|
|
18
|
+
|
|
19
|
+
const options: WatchOptions = {};
|
|
20
|
+
// Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
|
|
21
|
+
const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
|
|
22
|
+
let m: RegExpExecArray | null;
|
|
23
|
+
while ((m = kwRe.exec(rest)) !== null) {
|
|
24
|
+
const key = m[1] ?? m[5];
|
|
25
|
+
const val = m[2] ?? m[3] ?? m[4];
|
|
26
|
+
if (!key) continue;
|
|
27
|
+
switch (key) {
|
|
28
|
+
case 'goto':
|
|
29
|
+
options.goto = val;
|
|
30
|
+
break;
|
|
31
|
+
case 'dialog':
|
|
32
|
+
options.dialog = val;
|
|
33
|
+
break;
|
|
34
|
+
case 'run':
|
|
35
|
+
options.run = val;
|
|
36
|
+
break;
|
|
37
|
+
case 'name':
|
|
38
|
+
options.name = val;
|
|
39
|
+
break;
|
|
40
|
+
case 'priority':
|
|
41
|
+
options.priority = val ? Number(val) : 0;
|
|
42
|
+
break;
|
|
43
|
+
case 'once':
|
|
44
|
+
options.once = true;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Register during render (like {set}) — triggers survive navigation
|
|
50
|
+
// and are cleaned up via resetTriggers() on restart or {unwatch}.
|
|
51
|
+
const registered = hooks.useRef(false);
|
|
52
|
+
if (!registered.current) {
|
|
53
|
+
registered.current = true;
|
|
54
|
+
addTrigger(condition, options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
defineMacro({
|
|
62
|
+
name: 'unwatch',
|
|
63
|
+
render(props, ctx) {
|
|
64
|
+
const raw = props.rawArgs.trim();
|
|
65
|
+
const name = raw.replace(/^['"]|['"]$/g, '');
|
|
66
|
+
|
|
67
|
+
const ran = ctx.hooks.useRef(false);
|
|
68
|
+
if (!ran.current) {
|
|
69
|
+
ran.current = true;
|
|
70
|
+
removeTrigger(name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
},
|
|
75
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useStoryStore } from './store';
|
|
|
5
5
|
import { installStoryAPI } from './story-api';
|
|
6
6
|
import { resetIdCounters } from './action-registry';
|
|
7
7
|
import { executeStoryInit } from './story-init';
|
|
8
|
+
import { checkTriggers, reinitTriggerState } from './triggers';
|
|
8
9
|
import { loadSession } from './saves/save-manager';
|
|
9
10
|
import {
|
|
10
11
|
parseStoryVariables,
|
|
@@ -126,6 +127,21 @@ function boot() {
|
|
|
126
127
|
}
|
|
127
128
|
});
|
|
128
129
|
|
|
130
|
+
// Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
|
|
131
|
+
let prevVars = useStoryStore.getState().variables;
|
|
132
|
+
let prevHistoryIndex = useStoryStore.getState().historyIndex;
|
|
133
|
+
useStoryStore.subscribe((state) => {
|
|
134
|
+
if (state.variables !== prevVars) {
|
|
135
|
+
if (state.historyIndex === prevHistoryIndex) {
|
|
136
|
+
checkTriggers();
|
|
137
|
+
} else {
|
|
138
|
+
reinitTriggerState();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
prevVars = state.variables;
|
|
142
|
+
prevHistoryIndex = state.historyIndex;
|
|
143
|
+
});
|
|
144
|
+
|
|
129
145
|
// Warn if StoryInterface passage exists but doesn't contain {passage}
|
|
130
146
|
const storyInterfacePassage = storyData.passages.get('StoryInterface');
|
|
131
147
|
if (
|
package/src/store.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type { StoryData } from './parser';
|
|
10
10
|
import type { SavePayload, SaveHistoryMoment } from './saves/types';
|
|
11
11
|
import { executeStoryInit } from './story-init';
|
|
12
|
+
import { resetTriggers } from './triggers';
|
|
12
13
|
import {
|
|
13
14
|
initSaveSystem,
|
|
14
15
|
startNewPlaythrough,
|
|
@@ -441,6 +442,7 @@ export const useStoryStore = create<StoryState>()(
|
|
|
441
442
|
if (!startPassage) return;
|
|
442
443
|
|
|
443
444
|
resetPRNG();
|
|
445
|
+
resetTriggers();
|
|
444
446
|
const initialVars = deepClone(variableDefaults);
|
|
445
447
|
resetModuleState(deepClone(initialVars));
|
|
446
448
|
|
package/src/story-api.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
randomInt,
|
|
22
22
|
snapshotPRNG,
|
|
23
23
|
} from './prng';
|
|
24
|
+
import { addTrigger, removeTrigger } from './triggers';
|
|
25
|
+
import type { WatchOptions } from './triggers';
|
|
24
26
|
|
|
25
27
|
export type { StoryAction };
|
|
26
28
|
|
|
@@ -65,6 +67,11 @@ export interface StoryAPI {
|
|
|
65
67
|
on(event: 'actionsChanged', callback: ActionsChangedCallback): () => void;
|
|
66
68
|
on(event: 'variableChanged', callback: VariableChangedCallback): () => void;
|
|
67
69
|
waitForActions(): Promise<StoryAction[]>;
|
|
70
|
+
watch(
|
|
71
|
+
condition: string,
|
|
72
|
+
callbackOrOptions: (() => void) | WatchOptions,
|
|
73
|
+
): () => void;
|
|
74
|
+
unwatch(name: string): void;
|
|
68
75
|
random(): number;
|
|
69
76
|
randomInt(min: number, max: number): number;
|
|
70
77
|
readonly config: {
|
|
@@ -263,6 +270,17 @@ function createStoryAPI(): StoryAPI {
|
|
|
263
270
|
});
|
|
264
271
|
},
|
|
265
272
|
|
|
273
|
+
watch(
|
|
274
|
+
condition: string,
|
|
275
|
+
callbackOrOptions: (() => void) | WatchOptions,
|
|
276
|
+
): () => void {
|
|
277
|
+
return addTrigger(condition, callbackOrOptions);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
unwatch(name: string): void {
|
|
281
|
+
removeTrigger(name);
|
|
282
|
+
},
|
|
283
|
+
|
|
266
284
|
random(): number {
|
|
267
285
|
return random();
|
|
268
286
|
},
|
package/src/story-init.ts
CHANGED
|
@@ -1,63 +1,30 @@
|
|
|
1
|
+
import { h, render } from 'preact';
|
|
1
2
|
import { useStoryStore } from './store';
|
|
2
3
|
import { tokenize } from './markup/tokenizer';
|
|
3
4
|
import { buildAST } from './markup/ast';
|
|
4
|
-
import {
|
|
5
|
-
import type { ASTNode } from './markup/ast';
|
|
5
|
+
import { renderNodes } from './markup/render';
|
|
6
6
|
import { setSaveTitlePassage } from './saves/save-manager';
|
|
7
|
-
import { deepClone } from './class-registry';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
function walkAndExecute(
|
|
14
|
-
nodes: ASTNode[],
|
|
15
|
-
vars: Record<string, unknown>,
|
|
16
|
-
temps: Record<string, unknown>,
|
|
17
|
-
) {
|
|
18
|
-
for (const node of nodes) {
|
|
19
|
-
if (node.type !== 'macro') continue;
|
|
20
|
-
|
|
21
|
-
if (node.name === 'set') {
|
|
22
|
-
execute(node.rawArgs, vars, temps);
|
|
23
|
-
} else if (node.name === 'do') {
|
|
24
|
-
const code = node.children
|
|
25
|
-
.map((n) => (n.type === 'text' ? n.value : ''))
|
|
26
|
-
.join('');
|
|
27
|
-
execute(code, vars, temps);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Execute the StoryInit passage: tokenize, parse, and run all {set}/{do} macros.
|
|
34
|
-
* Called during boot and after restart() to ensure variables are initialized.
|
|
9
|
+
* Execute the StoryInit passage: tokenize, parse, and render all macros
|
|
10
|
+
* into a detached DOM node so their side effects fire through the normal
|
|
11
|
+
* Preact pipeline. This is macro-agnostic — any macro works in StoryInit.
|
|
35
12
|
*/
|
|
36
13
|
export function executeStoryInit() {
|
|
37
14
|
const state = useStoryStore.getState();
|
|
38
15
|
if (!state.storyData) return;
|
|
39
16
|
|
|
40
17
|
const storyInit = state.storyData.passages.get('StoryInit');
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Apply all changes to the store
|
|
52
|
-
for (const key of Object.keys(vars)) {
|
|
53
|
-
if (vars[key] !== state.variables[key]) {
|
|
54
|
-
state.setVariable(key, vars[key]);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
for (const key of Object.keys(temps)) {
|
|
58
|
-
if (temps[key] !== state.temporary[key]) {
|
|
59
|
-
state.setTemporary(key, temps[key]);
|
|
60
|
-
}
|
|
18
|
+
if (storyInit) {
|
|
19
|
+
const tokens = tokenize(storyInit.content);
|
|
20
|
+
const ast = buildAST(tokens);
|
|
21
|
+
|
|
22
|
+
const container = document.createElement('div');
|
|
23
|
+
render(
|
|
24
|
+
h(() => renderNodes(ast) as any, null),
|
|
25
|
+
container,
|
|
26
|
+
);
|
|
27
|
+
render(null, container);
|
|
61
28
|
}
|
|
62
29
|
|
|
63
30
|
// Register SaveTitle passage if it exists
|
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { evaluate, execute } from './expression';
|
|
2
|
+
import { useStoryStore } from './store';
|
|
3
|
+
|
|
4
|
+
export interface WatchOptions {
|
|
5
|
+
goto?: string;
|
|
6
|
+
dialog?: string;
|
|
7
|
+
run?: string;
|
|
8
|
+
once?: boolean;
|
|
9
|
+
name?: string;
|
|
10
|
+
priority?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Trigger {
|
|
14
|
+
id: number;
|
|
15
|
+
name?: string;
|
|
16
|
+
condition: string;
|
|
17
|
+
callback?: () => void;
|
|
18
|
+
options?: WatchOptions;
|
|
19
|
+
lastResult: boolean;
|
|
20
|
+
priority: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let nextId = 0;
|
|
24
|
+
let triggers: Trigger[] = [];
|
|
25
|
+
let checking = false;
|
|
26
|
+
|
|
27
|
+
type DialogCallback = () => void;
|
|
28
|
+
let dialogQueue: string[] = [];
|
|
29
|
+
let dialogNotify: DialogCallback | null = null;
|
|
30
|
+
|
|
31
|
+
function evalCondition(condition: string): boolean {
|
|
32
|
+
const state = useStoryStore.getState();
|
|
33
|
+
try {
|
|
34
|
+
return !!evaluate(condition, state.variables, state.temporary);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addTrigger(
|
|
41
|
+
condition: string,
|
|
42
|
+
callbackOrOptions: (() => void) | WatchOptions,
|
|
43
|
+
): () => void {
|
|
44
|
+
const id = nextId++;
|
|
45
|
+
const isCallback = typeof callbackOrOptions === 'function';
|
|
46
|
+
const options = isCallback ? undefined : callbackOrOptions;
|
|
47
|
+
const callback = isCallback ? callbackOrOptions : undefined;
|
|
48
|
+
|
|
49
|
+
const trigger: Trigger = {
|
|
50
|
+
id,
|
|
51
|
+
name: options?.name,
|
|
52
|
+
condition,
|
|
53
|
+
callback,
|
|
54
|
+
options,
|
|
55
|
+
lastResult: evalCondition(condition),
|
|
56
|
+
priority: options?.priority ?? 0,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
triggers.push(trigger);
|
|
60
|
+
triggers.sort((a, b) => b.priority - a.priority);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
triggers = triggers.filter((t) => t.id !== id);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function removeTrigger(name: string): void {
|
|
68
|
+
triggers = triggers.filter((t) => t.name !== name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fireTrigger(trigger: Trigger): void {
|
|
72
|
+
const { callback, options } = trigger;
|
|
73
|
+
|
|
74
|
+
if (callback) {
|
|
75
|
+
callback();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!options) return;
|
|
80
|
+
|
|
81
|
+
if (options.run) {
|
|
82
|
+
const state = useStoryStore.getState();
|
|
83
|
+
execute(options.run, state.variables, state.temporary);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.dialog) {
|
|
87
|
+
dialogQueue.push(options.dialog);
|
|
88
|
+
dialogNotify?.();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.goto) {
|
|
92
|
+
useStoryStore.getState().navigate(options.goto);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MAX_RECHECK_DEPTH = 10;
|
|
97
|
+
|
|
98
|
+
export function checkTriggers(): void {
|
|
99
|
+
if (checking) return;
|
|
100
|
+
checking = true;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
for (let depth = 0; depth < MAX_RECHECK_DEPTH; depth++) {
|
|
104
|
+
let anyFired = false;
|
|
105
|
+
|
|
106
|
+
// Snapshot triggers list — firing may remove `once` triggers
|
|
107
|
+
const current = [...triggers];
|
|
108
|
+
for (const trigger of current) {
|
|
109
|
+
// Skip if removed during this cycle
|
|
110
|
+
if (!triggers.includes(trigger)) continue;
|
|
111
|
+
|
|
112
|
+
const result = evalCondition(trigger.condition);
|
|
113
|
+
const wasFalse = !trigger.lastResult;
|
|
114
|
+
trigger.lastResult = result;
|
|
115
|
+
|
|
116
|
+
if (result && wasFalse) {
|
|
117
|
+
anyFired = true;
|
|
118
|
+
|
|
119
|
+
if (trigger.options?.once) {
|
|
120
|
+
triggers = triggers.filter((t) => t.id !== trigger.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
fireTrigger(trigger);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!anyFired) break;
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
checking = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function reinitTriggerState(): void {
|
|
135
|
+
for (const trigger of triggers) {
|
|
136
|
+
trigger.lastResult = evalCondition(trigger.condition);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resetTriggers(): void {
|
|
141
|
+
triggers = [];
|
|
142
|
+
dialogQueue = [];
|
|
143
|
+
nextId = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function subscribeTriggerDialogs(cb: () => void): () => void {
|
|
147
|
+
dialogNotify = cb;
|
|
148
|
+
// Flush any queued dialogs
|
|
149
|
+
if (dialogQueue.length > 0) cb();
|
|
150
|
+
return () => {
|
|
151
|
+
if (dialogNotify === cb) dialogNotify = null;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function shiftDialogQueue(): string | undefined {
|
|
156
|
+
return dialogQueue.shift();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function dialogQueueLength(): number {
|
|
160
|
+
return dialogQueue.length;
|
|
161
|
+
}
|