@kawaijs/runtime 0.1.11 → 0.1.13
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/README.md +16 -4
- package/dist/continue-link.d.ts +25 -0
- package/dist/continue-link.d.ts.map +1 -0
- package/dist/continue-link.js +144 -0
- package/dist/continue-link.js.map +1 -0
- package/dist/evaluator.d.ts +15 -2
- package/dist/evaluator.d.ts.map +1 -1
- package/dist/evaluator.js +157 -35
- package/dist/evaluator.js.map +1 -1
- package/dist/history.d.ts +5 -0
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +15 -1
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/save.d.ts +28 -4
- package/dist/save.d.ts.map +1 -1
- package/dist/save.js +130 -9
- package/dist/save.js.map +1 -1
- package/dist/state.d.ts +55 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +109 -4
- package/dist/state.js.map +1 -1
- package/dist/vm.d.ts +71 -6
- package/dist/vm.d.ts.map +1 -1
- package/dist/vm.js +696 -30
- package/dist/vm.js.map +1 -1
- package/package.json +10 -2
package/dist/vm.js
CHANGED
|
@@ -1,32 +1,91 @@
|
|
|
1
1
|
import { cloneState, createInitialState } from './state.js';
|
|
2
2
|
import { HistoryManager } from './history.js';
|
|
3
|
-
import { SaveManager } from './save.js';
|
|
4
|
-
import { applySetOperation, evaluateCondition } from './evaluator.js';
|
|
3
|
+
import { computeStoryHash, SaveManager } from './save.js';
|
|
4
|
+
import { applySetOperation, evaluateCondition, interpolateVariables, isSafeKey, resolveSpriteZ } from './evaluator.js';
|
|
5
5
|
export class StoryVM {
|
|
6
|
+
storyHash;
|
|
6
7
|
story;
|
|
7
8
|
state;
|
|
8
9
|
snapshotStack = [];
|
|
9
10
|
historyManager;
|
|
10
11
|
saveManager;
|
|
12
|
+
maxSnapshots;
|
|
13
|
+
maxCallDepth;
|
|
14
|
+
maxInstructionsPerBurst;
|
|
11
15
|
stateChangeListeners = new Set();
|
|
12
16
|
audioEventListeners = new Set();
|
|
17
|
+
cameraEventListeners = new Set();
|
|
18
|
+
errorListeners = new Set();
|
|
19
|
+
tickListeners = new Set();
|
|
20
|
+
executionTrace = [];
|
|
21
|
+
virtualTimeMs = 0;
|
|
13
22
|
isExecuting = false;
|
|
14
|
-
constructor(story,
|
|
23
|
+
constructor(story, saveManagerOrOptions) {
|
|
15
24
|
this.story = story;
|
|
16
|
-
|
|
25
|
+
this.storyHash = computeStoryHash(story);
|
|
26
|
+
const startLabel = story.meta?.startLabel ?? 'start';
|
|
17
27
|
this.state = createInitialState(startLabel);
|
|
18
28
|
this.historyManager = new HistoryManager();
|
|
19
|
-
|
|
29
|
+
let sm;
|
|
30
|
+
let maxSnaps = 250;
|
|
31
|
+
let maxDepth = 100;
|
|
32
|
+
let maxBurst = 10_000;
|
|
33
|
+
if (saveManagerOrOptions instanceof SaveManager) {
|
|
34
|
+
sm = saveManagerOrOptions;
|
|
35
|
+
}
|
|
36
|
+
else if (saveManagerOrOptions) {
|
|
37
|
+
sm = saveManagerOrOptions.saveManager;
|
|
38
|
+
if (typeof saveManagerOrOptions.maxSnapshots === 'number') {
|
|
39
|
+
maxSnaps = saveManagerOrOptions.maxSnapshots;
|
|
40
|
+
}
|
|
41
|
+
if (typeof saveManagerOrOptions.maxCallDepth === 'number') {
|
|
42
|
+
maxDepth = saveManagerOrOptions.maxCallDepth;
|
|
43
|
+
}
|
|
44
|
+
if (typeof saveManagerOrOptions.maxInstructionsPerBurst === 'number') {
|
|
45
|
+
maxBurst = saveManagerOrOptions.maxInstructionsPerBurst;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.saveManager = sm ?? new SaveManager();
|
|
49
|
+
this.maxSnapshots = Math.max(1, maxSnaps);
|
|
50
|
+
this.maxCallDepth = Math.max(1, maxDepth);
|
|
51
|
+
this.maxInstructionsPerBurst = Math.max(1, maxBurst);
|
|
20
52
|
}
|
|
21
53
|
getState() {
|
|
22
54
|
return this.state;
|
|
23
55
|
}
|
|
56
|
+
getStory() {
|
|
57
|
+
return this.story;
|
|
58
|
+
}
|
|
24
59
|
getHistoryManager() {
|
|
25
60
|
return this.historyManager;
|
|
26
61
|
}
|
|
27
62
|
getSaveManager() {
|
|
28
63
|
return this.saveManager;
|
|
29
64
|
}
|
|
65
|
+
getExecutionTrace() {
|
|
66
|
+
return [...this.executionTrace];
|
|
67
|
+
}
|
|
68
|
+
clearExecutionTrace() {
|
|
69
|
+
this.executionTrace = [];
|
|
70
|
+
}
|
|
71
|
+
recordTrace(entry) {
|
|
72
|
+
this.executionTrace.push(entry);
|
|
73
|
+
}
|
|
74
|
+
getVirtualTime() {
|
|
75
|
+
return this.virtualTimeMs;
|
|
76
|
+
}
|
|
77
|
+
tick(deltaMs) {
|
|
78
|
+
if (deltaMs <= 0)
|
|
79
|
+
return;
|
|
80
|
+
this.virtualTimeMs += deltaMs;
|
|
81
|
+
for (const listener of this.tickListeners) {
|
|
82
|
+
listener(deltaMs, this.virtualTimeMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
onTick(listener) {
|
|
86
|
+
this.tickListeners.add(listener);
|
|
87
|
+
return () => this.tickListeners.delete(listener);
|
|
88
|
+
}
|
|
30
89
|
onStateChange(listener) {
|
|
31
90
|
this.stateChangeListeners.add(listener);
|
|
32
91
|
return () => this.stateChangeListeners.delete(listener);
|
|
@@ -35,13 +94,42 @@ export class StoryVM {
|
|
|
35
94
|
this.audioEventListeners.add(listener);
|
|
36
95
|
return () => this.audioEventListeners.delete(listener);
|
|
37
96
|
}
|
|
38
|
-
|
|
39
|
-
|
|
97
|
+
onCameraEvent(listener) {
|
|
98
|
+
this.cameraEventListeners.add(listener);
|
|
99
|
+
return () => this.cameraEventListeners.delete(listener);
|
|
100
|
+
}
|
|
101
|
+
onError(listener) {
|
|
102
|
+
this.errorListeners.add(listener);
|
|
103
|
+
return () => this.errorListeners.delete(listener);
|
|
104
|
+
}
|
|
105
|
+
emitCameraEvent(event) {
|
|
106
|
+
for (const listener of this.cameraEventListeners) {
|
|
107
|
+
listener(event);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
emitError(error) {
|
|
111
|
+
for (const listener of this.errorListeners) {
|
|
112
|
+
try {
|
|
113
|
+
listener(error);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
start(atLabel) {
|
|
119
|
+
if (this.isExecuting) {
|
|
120
|
+
throw new Error('Cannot call start() while the story VM is already executing instructions.');
|
|
121
|
+
}
|
|
122
|
+
const startLabel = atLabel ?? this.story.meta?.startLabel ?? 'start';
|
|
40
123
|
if (!this.story.labels[startLabel]) {
|
|
41
124
|
throw new Error(`Cannot start story: Start label '${startLabel}' not found in story package.`);
|
|
42
125
|
}
|
|
126
|
+
const prevMusic = this.state.audio.music;
|
|
127
|
+
this.recordTrace(`START ${startLabel}`);
|
|
43
128
|
this.state = createInitialState(startLabel);
|
|
44
129
|
this.snapshotStack = [];
|
|
130
|
+
this.virtualTimeMs = 0;
|
|
131
|
+
this.historyManager.clear();
|
|
132
|
+
this.resyncAudio(prevMusic);
|
|
45
133
|
this.executeUntilWaiting();
|
|
46
134
|
}
|
|
47
135
|
/**
|
|
@@ -54,9 +142,40 @@ export class StoryVM {
|
|
|
54
142
|
// Must make a choice; cannot advance automatically
|
|
55
143
|
return;
|
|
56
144
|
}
|
|
145
|
+
if (this.state.hotspots && this.state.hotspots.length > 0) {
|
|
146
|
+
// Must click a hotspot; stage click must not skip.
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (this.state.pendingInput) {
|
|
150
|
+
// Must call submitInput(); clicking the stage must not skip the prompt.
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
57
153
|
this.state = {
|
|
58
154
|
...this.state,
|
|
59
|
-
isWaitingForInput: false
|
|
155
|
+
isWaitingForInput: false,
|
|
156
|
+
pendingPauseMs: null
|
|
157
|
+
};
|
|
158
|
+
this.executeUntilWaiting();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Completes a pending `input` prompt and continues the story.
|
|
162
|
+
*/
|
|
163
|
+
submitInput(value) {
|
|
164
|
+
if (this.isExecuting || !this.state.pendingInput)
|
|
165
|
+
return;
|
|
166
|
+
const variable = this.state.pendingInput.variable;
|
|
167
|
+
const trimmed = String(value ?? '').trim();
|
|
168
|
+
this.recordTrace(`INPUT ${variable}="${trimmed}"`);
|
|
169
|
+
const nextVars = { ...this.state.variables };
|
|
170
|
+
if (isSafeKey(variable)) {
|
|
171
|
+
nextVars[variable] = trimmed;
|
|
172
|
+
}
|
|
173
|
+
this.state = {
|
|
174
|
+
...this.state,
|
|
175
|
+
variables: nextVars,
|
|
176
|
+
pendingInput: null,
|
|
177
|
+
isWaitingForInput: false,
|
|
178
|
+
pendingPauseMs: null
|
|
60
179
|
};
|
|
61
180
|
this.executeUntilWaiting();
|
|
62
181
|
}
|
|
@@ -68,27 +187,86 @@ export class StoryVM {
|
|
|
68
187
|
return;
|
|
69
188
|
}
|
|
70
189
|
const choice = this.state.choices[choiceIndex];
|
|
190
|
+
this.recordTrace(`CHOOSE ${choiceIndex} (${choice.text}) -> ${choice.targetLabel}`);
|
|
191
|
+
if (!this.story.labels[choice.targetLabel]) {
|
|
192
|
+
const err = new Error(`Runtime Error: Choice target label '${choice.targetLabel}' is not defined in story.`);
|
|
193
|
+
this.emitError(err);
|
|
194
|
+
this.state = {
|
|
195
|
+
...this.state,
|
|
196
|
+
choices: null,
|
|
197
|
+
hotspots: null,
|
|
198
|
+
pendingPauseMs: null,
|
|
199
|
+
isFinished: true,
|
|
200
|
+
isWaitingForInput: false
|
|
201
|
+
};
|
|
202
|
+
this.notifyStateChanged();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
71
205
|
this.state = {
|
|
72
206
|
...this.state,
|
|
73
207
|
choices: null,
|
|
208
|
+
hotspots: null,
|
|
74
209
|
isWaitingForInput: false,
|
|
210
|
+
pendingPauseMs: null,
|
|
75
211
|
currentLabel: choice.targetLabel,
|
|
76
212
|
instructionPointer: 0
|
|
77
213
|
};
|
|
78
214
|
this.executeUntilWaiting();
|
|
79
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* Selects a clickable hotspot and jumps to its target label.
|
|
218
|
+
*/
|
|
219
|
+
selectHotspot(id) {
|
|
220
|
+
if (this.isExecuting || !this.state.hotspots || this.state.hotspots.length === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const hotspot = this.state.hotspots.find((h) => h.id === id);
|
|
224
|
+
if (!hotspot)
|
|
225
|
+
return;
|
|
226
|
+
this.recordTrace(`HOTSPOT ${id} -> ${hotspot.targetLabel}`);
|
|
227
|
+
if (!this.story.labels[hotspot.targetLabel]) {
|
|
228
|
+
const err = new Error(`Runtime Error: Hotspot target label '${hotspot.targetLabel}' is not defined in story.`);
|
|
229
|
+
this.emitError(err);
|
|
230
|
+
this.state = {
|
|
231
|
+
...this.state,
|
|
232
|
+
hotspots: null,
|
|
233
|
+
pendingPauseMs: null,
|
|
234
|
+
isFinished: true,
|
|
235
|
+
isWaitingForInput: false
|
|
236
|
+
};
|
|
237
|
+
this.notifyStateChanged();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.state = {
|
|
241
|
+
...this.state,
|
|
242
|
+
hotspots: null,
|
|
243
|
+
choices: null,
|
|
244
|
+
isWaitingForInput: false,
|
|
245
|
+
pendingPauseMs: null,
|
|
246
|
+
windowVisible: true,
|
|
247
|
+
currentLabel: hotspot.targetLabel,
|
|
248
|
+
instructionPointer: 0
|
|
249
|
+
};
|
|
250
|
+
this.executeUntilWaiting();
|
|
251
|
+
}
|
|
80
252
|
/**
|
|
81
253
|
* Jumps directly to a label.
|
|
82
254
|
*/
|
|
83
255
|
jump(labelName) {
|
|
256
|
+
if (this.isExecuting) {
|
|
257
|
+
throw new Error('Cannot call jump() while the story VM is already executing instructions.');
|
|
258
|
+
}
|
|
84
259
|
if (!this.story.labels[labelName]) {
|
|
85
260
|
throw new Error(`Target label '${labelName}' not found in story package.`);
|
|
86
261
|
}
|
|
262
|
+
this.recordTrace(`JUMP_MANUAL ${labelName}`);
|
|
87
263
|
this.state = {
|
|
88
264
|
...this.state,
|
|
89
265
|
currentLabel: labelName,
|
|
90
266
|
instructionPointer: 0,
|
|
91
267
|
choices: null,
|
|
268
|
+
hotspots: null,
|
|
269
|
+
pendingPauseMs: null,
|
|
92
270
|
isWaitingForInput: false
|
|
93
271
|
};
|
|
94
272
|
this.executeUntilWaiting();
|
|
@@ -100,12 +278,17 @@ export class StoryVM {
|
|
|
100
278
|
if (this.snapshotStack.length <= 1) {
|
|
101
279
|
return false;
|
|
102
280
|
}
|
|
281
|
+
const prevMusic = this.state.audio.music;
|
|
103
282
|
// Pop current snapshot
|
|
104
283
|
this.snapshotStack.pop();
|
|
105
284
|
// Restore previous snapshot
|
|
106
285
|
const prev = this.snapshotStack[this.snapshotStack.length - 1];
|
|
107
286
|
if (prev) {
|
|
108
287
|
this.state = cloneState(prev.state);
|
|
288
|
+
const histLen = typeof prev.historyLength === 'number' ? prev.historyLength : this.historyManager.getLength();
|
|
289
|
+
this.historyManager.trimTo(histLen);
|
|
290
|
+
this.recordTrace(`ROLLBACK`);
|
|
291
|
+
this.resyncAudio(prevMusic);
|
|
109
292
|
this.notifyStateChanged();
|
|
110
293
|
return true;
|
|
111
294
|
}
|
|
@@ -117,28 +300,94 @@ export class StoryVM {
|
|
|
117
300
|
async save(slotId) {
|
|
118
301
|
const currentSnapshot = this.captureSnapshot();
|
|
119
302
|
const previewText = this.state.dialogue?.text ?? 'Game in progress';
|
|
120
|
-
|
|
303
|
+
this.recordTrace(`SAVE slot_${slotId}`);
|
|
304
|
+
return await this.saveManager.saveSlot(slotId, currentSnapshot, previewText, this.storyHash, this.historyManager.getEntries());
|
|
121
305
|
}
|
|
122
|
-
async load(slotId) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
306
|
+
async load(slotId, validateStoryHash = true) {
|
|
307
|
+
const res = await this.loadWithDetails(slotId, validateStoryHash);
|
|
308
|
+
return res.success;
|
|
309
|
+
}
|
|
310
|
+
async loadWithDetails(slotId, validateStoryHash = true) {
|
|
311
|
+
const res = await this.saveManager.loadSlot(slotId, validateStoryHash ? this.storyHash : undefined);
|
|
312
|
+
if (!res.success || !res.slot) {
|
|
313
|
+
return res;
|
|
314
|
+
}
|
|
315
|
+
this.applyLoadedSlot(res.slot, `LOAD slot_${slotId}`);
|
|
316
|
+
return res;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Restore from an in-memory SaveSlot (continue-links, imported saves).
|
|
320
|
+
* Validates storyHash when present on the slot.
|
|
321
|
+
*/
|
|
322
|
+
loadFromSlot(slot, validateStoryHash = true) {
|
|
323
|
+
if (validateStoryHash && slot.storyHash && slot.storyHash !== this.storyHash) {
|
|
324
|
+
return { success: false, reason: 'incompatible_story', slot };
|
|
325
|
+
}
|
|
326
|
+
this.applyLoadedSlot(slot, `LOAD continue_${slot.id}`);
|
|
327
|
+
return { success: true, slot };
|
|
328
|
+
}
|
|
329
|
+
applyLoadedSlot(slot, traceLabel) {
|
|
330
|
+
const prevMusic = this.state.audio.music;
|
|
126
331
|
this.state = cloneState(slot.snapshot.state);
|
|
127
|
-
|
|
332
|
+
const restoredHistory = slot.historyEntries ?? [];
|
|
333
|
+
this.historyManager.replaceAll(restoredHistory);
|
|
334
|
+
const snap = {
|
|
335
|
+
...slot.snapshot,
|
|
336
|
+
historyLength: typeof slot.snapshot.historyLength === 'number'
|
|
337
|
+
? slot.snapshot.historyLength
|
|
338
|
+
: restoredHistory.length
|
|
339
|
+
};
|
|
340
|
+
this.snapshotStack = [snap];
|
|
341
|
+
this.recordTrace(traceLabel);
|
|
342
|
+
this.resyncAudio(prevMusic);
|
|
128
343
|
this.notifyStateChanged();
|
|
129
|
-
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Emit stop/play audio events so the presentation layer matches restored state
|
|
347
|
+
* after load or rollback (snapshots store track ids but not live Audio elements).
|
|
348
|
+
*/
|
|
349
|
+
resyncAudio(previousMusic) {
|
|
350
|
+
this.emitAudioEvent({ action: 'stop', channel: 'voice' });
|
|
351
|
+
this.emitAudioEvent({ action: 'stop', channel: 'sound' });
|
|
352
|
+
const nextMusic = this.state.audio.music;
|
|
353
|
+
if (previousMusic === nextMusic) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (nextMusic) {
|
|
357
|
+
this.emitAudioEvent({
|
|
358
|
+
action: 'play',
|
|
359
|
+
channel: 'music',
|
|
360
|
+
track: nextMusic,
|
|
361
|
+
loop: true
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
this.emitAudioEvent({ action: 'stop', channel: 'music' });
|
|
366
|
+
}
|
|
130
367
|
}
|
|
131
368
|
executeUntilWaiting() {
|
|
132
369
|
if (this.isExecuting)
|
|
133
370
|
return;
|
|
134
371
|
this.isExecuting = true;
|
|
135
372
|
try {
|
|
373
|
+
let instructionsExecuted = 0;
|
|
136
374
|
while (!this.state.isWaitingForInput && !this.state.isFinished) {
|
|
375
|
+
if (instructionsExecuted >= this.maxInstructionsPerBurst) {
|
|
376
|
+
const err = new Error(`Runtime Error: Exceeded maximum of ${this.maxInstructionsPerBurst} instructions without waiting for input (possible infinite loop at label '${this.state.currentLabel}').`);
|
|
377
|
+
this.emitError(err);
|
|
378
|
+
this.state = {
|
|
379
|
+
...this.state,
|
|
380
|
+
isFinished: true,
|
|
381
|
+
isWaitingForInput: false
|
|
382
|
+
};
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
137
385
|
const labelInstructions = this.story.labels[this.state.currentLabel];
|
|
138
386
|
if (!labelInstructions || this.state.instructionPointer >= labelInstructions.length) {
|
|
139
387
|
// Handle end of label: check call stack
|
|
140
388
|
if (this.state.callStack.length > 0) {
|
|
141
389
|
const topFrame = this.state.callStack[this.state.callStack.length - 1];
|
|
390
|
+
this.recordTrace(`RETURN -> ${topFrame.returnLabel}:${topFrame.returnPointer}`);
|
|
142
391
|
this.state = {
|
|
143
392
|
...this.state,
|
|
144
393
|
currentLabel: topFrame.returnLabel,
|
|
@@ -148,6 +397,7 @@ export class StoryVM {
|
|
|
148
397
|
continue;
|
|
149
398
|
}
|
|
150
399
|
// Story finished
|
|
400
|
+
this.recordTrace('END');
|
|
151
401
|
this.state = {
|
|
152
402
|
...this.state,
|
|
153
403
|
isFinished: true,
|
|
@@ -161,6 +411,7 @@ export class StoryVM {
|
|
|
161
411
|
instructionPointer: this.state.instructionPointer + 1
|
|
162
412
|
};
|
|
163
413
|
this.executeInstruction(inst);
|
|
414
|
+
instructionsExecuted++;
|
|
164
415
|
}
|
|
165
416
|
if (this.state.isWaitingForInput) {
|
|
166
417
|
this.recordSnapshot();
|
|
@@ -174,18 +425,26 @@ export class StoryVM {
|
|
|
174
425
|
executeInstruction(inst) {
|
|
175
426
|
switch (inst.type) {
|
|
176
427
|
case 'scene': {
|
|
428
|
+
this.recordTrace(`SCENE ${inst.background}${inst.transition ? ` [${inst.transition}]` : ''}`);
|
|
177
429
|
this.state = {
|
|
178
430
|
...this.state,
|
|
179
431
|
visual: {
|
|
180
432
|
background: inst.background,
|
|
181
433
|
transition: inst.transition ?? null,
|
|
182
|
-
characters: {} // Clear characters on new scene
|
|
183
|
-
|
|
434
|
+
characters: {}, // Clear characters on new scene
|
|
435
|
+
vfx: null,
|
|
436
|
+
activeCG: null,
|
|
437
|
+
defaultLayer: this.state.visual.defaultLayer
|
|
438
|
+
},
|
|
439
|
+
hotspots: null
|
|
184
440
|
};
|
|
185
441
|
break;
|
|
186
442
|
}
|
|
187
443
|
case 'show': {
|
|
444
|
+
this.recordTrace(`SHOW ${inst.character}${inst.expression ? ` ${inst.expression}` : ''}${inst.position ? ` at ${inst.position}` : ''}`);
|
|
188
445
|
const charDef = this.state.visual.characters[inst.character] ?? {};
|
|
446
|
+
const layer = inst.layer ?? charDef.layer ?? this.state.visual.defaultLayer ?? undefined;
|
|
447
|
+
const z = resolveSpriteZ(layer, inst.z ?? charDef.z);
|
|
189
448
|
this.state = {
|
|
190
449
|
...this.state,
|
|
191
450
|
visual: {
|
|
@@ -194,7 +453,11 @@ export class StoryVM {
|
|
|
194
453
|
...this.state.visual.characters,
|
|
195
454
|
[inst.character]: {
|
|
196
455
|
expression: inst.expression ?? charDef.expression,
|
|
197
|
-
position: inst.position ?? charDef.position ?? 'center'
|
|
456
|
+
position: inst.position ?? charDef.position ?? 'center',
|
|
457
|
+
transition: inst.transition ?? charDef.transition,
|
|
458
|
+
layer,
|
|
459
|
+
z,
|
|
460
|
+
cssAnimation: charDef.cssAnimation
|
|
198
461
|
}
|
|
199
462
|
}
|
|
200
463
|
}
|
|
@@ -202,6 +465,7 @@ export class StoryVM {
|
|
|
202
465
|
break;
|
|
203
466
|
}
|
|
204
467
|
case 'hide': {
|
|
468
|
+
this.recordTrace(`HIDE ${inst.character}`);
|
|
205
469
|
const nextChars = { ...this.state.visual.characters };
|
|
206
470
|
delete nextChars[inst.character];
|
|
207
471
|
this.state = {
|
|
@@ -217,50 +481,236 @@ export class StoryVM {
|
|
|
217
481
|
const charDef = inst.speaker ? this.story.characters[inst.speaker] : undefined;
|
|
218
482
|
const displayName = charDef?.name ?? inst.speaker;
|
|
219
483
|
const color = charDef?.color;
|
|
484
|
+
const interpolatedText = interpolateVariables(inst.text, this.state.variables, {
|
|
485
|
+
lang: this.state.lang,
|
|
486
|
+
i18n: this.story.i18n
|
|
487
|
+
});
|
|
488
|
+
this.recordTrace(`DIALOGUE ${displayName ? `[${displayName}] ` : ''}${interpolatedText}`);
|
|
220
489
|
this.state = {
|
|
221
490
|
...this.state,
|
|
222
491
|
dialogue: {
|
|
223
492
|
speaker: inst.speaker,
|
|
224
493
|
speakerDisplayName: displayName,
|
|
225
494
|
speakerColor: color,
|
|
226
|
-
text:
|
|
495
|
+
text: interpolatedText
|
|
227
496
|
},
|
|
497
|
+
hotspots: null,
|
|
498
|
+
pendingPauseMs: null,
|
|
228
499
|
isWaitingForInput: true
|
|
229
500
|
};
|
|
230
|
-
this.historyManager.addEntry(inst.speaker, displayName,
|
|
501
|
+
this.historyManager.addEntry(inst.speaker, displayName, interpolatedText);
|
|
231
502
|
break;
|
|
232
503
|
}
|
|
233
504
|
case 'choice': {
|
|
505
|
+
const availableChoices = inst.choices
|
|
506
|
+
.filter(choice => {
|
|
507
|
+
if (!choice.condition)
|
|
508
|
+
return true;
|
|
509
|
+
return evaluateCondition(choice.condition, this.state.variables);
|
|
510
|
+
})
|
|
511
|
+
.map(choice => ({
|
|
512
|
+
...choice,
|
|
513
|
+
text: interpolateVariables(choice.text, this.state.variables, {
|
|
514
|
+
lang: this.state.lang,
|
|
515
|
+
i18n: this.story.i18n
|
|
516
|
+
})
|
|
517
|
+
}));
|
|
518
|
+
this.recordTrace(`CHOICES [${availableChoices.map(c => c.text).join(', ')}]`);
|
|
519
|
+
if (availableChoices.length === 0) {
|
|
520
|
+
// No visible choices — jump to menu fallthrough rather than soft-locking.
|
|
521
|
+
const fallback = inst.fallbackLabel;
|
|
522
|
+
const err = new Error('Runtime Warning: Choice menu evaluated to zero available options; continuing.');
|
|
523
|
+
this.emitError(err);
|
|
524
|
+
if (fallback && this.story.labels[fallback]) {
|
|
525
|
+
this.state = {
|
|
526
|
+
...this.state,
|
|
527
|
+
choices: null,
|
|
528
|
+
pendingPauseMs: null,
|
|
529
|
+
isWaitingForInput: false,
|
|
530
|
+
currentLabel: fallback,
|
|
531
|
+
instructionPointer: 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
this.state = {
|
|
536
|
+
...this.state,
|
|
537
|
+
choices: null,
|
|
538
|
+
pendingPauseMs: null,
|
|
539
|
+
isWaitingForInput: false
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
234
544
|
this.state = {
|
|
235
545
|
...this.state,
|
|
236
|
-
choices:
|
|
546
|
+
choices: availableChoices,
|
|
547
|
+
hotspots: null,
|
|
548
|
+
pendingPauseMs: null,
|
|
237
549
|
isWaitingForInput: true
|
|
238
550
|
};
|
|
239
551
|
break;
|
|
240
552
|
}
|
|
553
|
+
case 'vfx': {
|
|
554
|
+
this.recordTrace(`VFX ${inst.effect}${inst.intensity !== undefined ? ` ${inst.intensity}` : ''}${inst.color ? ` ${inst.color}` : ''}`);
|
|
555
|
+
if (inst.effect === 'stop') {
|
|
556
|
+
this.state = {
|
|
557
|
+
...this.state,
|
|
558
|
+
visual: {
|
|
559
|
+
...this.state.visual,
|
|
560
|
+
vfx: null
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
const prev = this.state.visual.vfx;
|
|
566
|
+
// Tint is an overlay: keep rain/sakura/snow/fog particles running underneath.
|
|
567
|
+
if (inst.effect === 'tint') {
|
|
568
|
+
const baseEffect = prev && prev.effect !== 'tint' && prev.effect !== 'stop' ? prev.effect : 'tint';
|
|
569
|
+
this.state = {
|
|
570
|
+
...this.state,
|
|
571
|
+
visual: {
|
|
572
|
+
...this.state.visual,
|
|
573
|
+
vfx: {
|
|
574
|
+
effect: baseEffect,
|
|
575
|
+
intensity: prev?.intensity,
|
|
576
|
+
color: inst.color ?? (typeof inst.intensity === 'string' ? inst.intensity : undefined)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
this.state = {
|
|
583
|
+
...this.state,
|
|
584
|
+
visual: {
|
|
585
|
+
...this.state.visual,
|
|
586
|
+
vfx: {
|
|
587
|
+
effect: inst.effect,
|
|
588
|
+
intensity: inst.intensity,
|
|
589
|
+
// Preserve active tint color when switching weather effects
|
|
590
|
+
color: prev?.color
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case 'camera': {
|
|
597
|
+
this.recordTrace(`CAMERA ${inst.action}${inst.duration !== undefined ? ` ${inst.duration}` : ''}`);
|
|
598
|
+
this.emitCameraEvent({
|
|
599
|
+
action: inst.action,
|
|
600
|
+
duration: inst.duration
|
|
601
|
+
});
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case 'pause': {
|
|
605
|
+
this.recordTrace(`PAUSE${inst.duration !== undefined ? ` ${inst.duration}` : ''}`);
|
|
606
|
+
// Duration is treated as milliseconds (matches README / parser examples like `pause 1200`).
|
|
607
|
+
this.state = {
|
|
608
|
+
...this.state,
|
|
609
|
+
pendingPauseMs: inst.duration !== undefined && inst.duration > 0 ? inst.duration : null,
|
|
610
|
+
isWaitingForInput: true
|
|
611
|
+
};
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
case 'cg': {
|
|
615
|
+
const unlockKey = inst.unlockId || inst.image;
|
|
616
|
+
this.recordTrace(`CG ${inst.image}`);
|
|
617
|
+
const nextUnlocked = Object.assign(Object.create(null), this.state.unlockedCGs);
|
|
618
|
+
nextUnlocked[unlockKey] = true;
|
|
619
|
+
this.state = {
|
|
620
|
+
...this.state,
|
|
621
|
+
visual: {
|
|
622
|
+
...this.state.visual,
|
|
623
|
+
activeCG: inst.image
|
|
624
|
+
},
|
|
625
|
+
unlockedCGs: nextUnlocked
|
|
626
|
+
};
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
241
629
|
case 'jump': {
|
|
630
|
+
const target = inst.targetLabel;
|
|
631
|
+
this.recordTrace(`JUMP ${target}`);
|
|
632
|
+
if (!this.story.labels[target]) {
|
|
633
|
+
const err = new Error(`Runtime Error: Jump target label '${target}' is not defined in story.`);
|
|
634
|
+
this.emitError(err);
|
|
635
|
+
this.state = {
|
|
636
|
+
...this.state,
|
|
637
|
+
isFinished: true,
|
|
638
|
+
isWaitingForInput: false
|
|
639
|
+
};
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
242
642
|
this.state = {
|
|
243
643
|
...this.state,
|
|
244
|
-
currentLabel:
|
|
644
|
+
currentLabel: target,
|
|
645
|
+
instructionPointer: 0
|
|
646
|
+
};
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
case 'call': {
|
|
650
|
+
const target = inst.targetLabel;
|
|
651
|
+
this.recordTrace(`CALL ${target}`);
|
|
652
|
+
if (this.state.callStack.length >= this.maxCallDepth) {
|
|
653
|
+
const err = new Error(`Runtime Error: Maximum call stack depth of ${this.maxCallDepth} exceeded.`);
|
|
654
|
+
this.emitError(err);
|
|
655
|
+
this.state = {
|
|
656
|
+
...this.state,
|
|
657
|
+
isFinished: true,
|
|
658
|
+
isWaitingForInput: false
|
|
659
|
+
};
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
if (!this.story.labels[target]) {
|
|
663
|
+
const err = new Error(`Runtime Error: Call target label '${target}' is not defined in story.`);
|
|
664
|
+
this.emitError(err);
|
|
665
|
+
this.state = {
|
|
666
|
+
...this.state,
|
|
667
|
+
isFinished: true,
|
|
668
|
+
isWaitingForInput: false
|
|
669
|
+
};
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
this.state = {
|
|
673
|
+
...this.state,
|
|
674
|
+
callStack: [
|
|
675
|
+
...this.state.callStack,
|
|
676
|
+
{
|
|
677
|
+
returnLabel: this.state.currentLabel,
|
|
678
|
+
returnPointer: this.state.instructionPointer
|
|
679
|
+
}
|
|
680
|
+
],
|
|
681
|
+
currentLabel: target,
|
|
245
682
|
instructionPointer: 0
|
|
246
683
|
};
|
|
247
684
|
break;
|
|
248
685
|
}
|
|
249
686
|
case 'set': {
|
|
250
687
|
const currentVal = this.state.variables[inst.variable];
|
|
251
|
-
const nextVal = applySetOperation(currentVal, inst.operator, inst.value, this.state.variables);
|
|
688
|
+
const nextVal = applySetOperation(currentVal, inst.operator, inst.value, this.state.variables, inst.isVariable);
|
|
689
|
+
this.recordTrace(`SET ${inst.variable} ${inst.operator ?? '='} ${String(nextVal)}`);
|
|
690
|
+
const nextVars = Object.assign(Object.create(null), this.state.variables);
|
|
691
|
+
if (isSafeKey(inst.variable)) {
|
|
692
|
+
nextVars[inst.variable] = nextVal;
|
|
693
|
+
}
|
|
252
694
|
this.state = {
|
|
253
695
|
...this.state,
|
|
254
|
-
variables:
|
|
255
|
-
...this.state.variables,
|
|
256
|
-
[inst.variable]: nextVal
|
|
257
|
-
}
|
|
696
|
+
variables: nextVars
|
|
258
697
|
};
|
|
259
698
|
break;
|
|
260
699
|
}
|
|
261
700
|
case 'branch': {
|
|
262
701
|
const conditionVal = evaluateCondition(inst.condition, this.state.variables);
|
|
263
702
|
const targetLabel = conditionVal ? inst.thenLabel : (inst.elseLabel ?? inst.thenLabel);
|
|
703
|
+
this.recordTrace(`BRANCH ${inst.condition} (${conditionVal}) -> ${targetLabel}`);
|
|
704
|
+
if (!this.story.labels[targetLabel]) {
|
|
705
|
+
const err = new Error(`Runtime Error: Branch target label '${targetLabel}' is not defined in story.`);
|
|
706
|
+
this.emitError(err);
|
|
707
|
+
this.state = {
|
|
708
|
+
...this.state,
|
|
709
|
+
isFinished: true,
|
|
710
|
+
isWaitingForInput: false
|
|
711
|
+
};
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
264
714
|
this.state = {
|
|
265
715
|
...this.state,
|
|
266
716
|
currentLabel: targetLabel,
|
|
@@ -269,6 +719,7 @@ export class StoryVM {
|
|
|
269
719
|
break;
|
|
270
720
|
}
|
|
271
721
|
case 'play_audio': {
|
|
722
|
+
this.recordTrace(`PLAY_AUDIO ${inst.channel}:${inst.track}`);
|
|
272
723
|
this.emitAudioEvent({
|
|
273
724
|
action: 'play',
|
|
274
725
|
channel: inst.channel,
|
|
@@ -288,6 +739,7 @@ export class StoryVM {
|
|
|
288
739
|
break;
|
|
289
740
|
}
|
|
290
741
|
case 'stop_audio': {
|
|
742
|
+
this.recordTrace(`STOP_AUDIO ${inst.channel}`);
|
|
291
743
|
this.emitAudioEvent({
|
|
292
744
|
action: 'stop',
|
|
293
745
|
channel: inst.channel,
|
|
@@ -304,9 +756,162 @@ export class StoryVM {
|
|
|
304
756
|
}
|
|
305
757
|
break;
|
|
306
758
|
}
|
|
759
|
+
case 'input': {
|
|
760
|
+
const prompt = interpolateVariables(inst.prompt, this.state.variables, {
|
|
761
|
+
lang: this.state.lang,
|
|
762
|
+
i18n: this.story.i18n
|
|
763
|
+
});
|
|
764
|
+
this.recordTrace(`INPUT_WAIT ${inst.variable} "${prompt}"`);
|
|
765
|
+
this.state = {
|
|
766
|
+
...this.state,
|
|
767
|
+
pendingInput: { variable: inst.variable, prompt },
|
|
768
|
+
choices: null,
|
|
769
|
+
hotspots: null,
|
|
770
|
+
pendingPauseMs: null,
|
|
771
|
+
isWaitingForInput: true
|
|
772
|
+
};
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
case 'window': {
|
|
776
|
+
this.recordTrace(`WINDOW ${inst.action}`);
|
|
777
|
+
this.state = {
|
|
778
|
+
...this.state,
|
|
779
|
+
windowVisible: inst.action === 'show'
|
|
780
|
+
};
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case 'theme': {
|
|
784
|
+
const name = sanitizeCssToken(inst.name);
|
|
785
|
+
this.recordTrace(`THEME ${name}`);
|
|
786
|
+
this.state = {
|
|
787
|
+
...this.state,
|
|
788
|
+
theme: name || null
|
|
789
|
+
};
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
case 'style': {
|
|
793
|
+
const target = sanitizeCssToken(inst.target) || 'root';
|
|
794
|
+
const name = sanitizeCssToken(inst.name) || 'default';
|
|
795
|
+
this.recordTrace(`STYLE ${target}=${name}`);
|
|
796
|
+
this.state = {
|
|
797
|
+
...this.state,
|
|
798
|
+
styleClasses: {
|
|
799
|
+
...this.state.styleClasses,
|
|
800
|
+
[target]: name
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case 'hotspot': {
|
|
806
|
+
const collected = [
|
|
807
|
+
{
|
|
808
|
+
id: inst.id,
|
|
809
|
+
x: inst.x,
|
|
810
|
+
y: inst.y,
|
|
811
|
+
w: inst.w,
|
|
812
|
+
h: inst.h,
|
|
813
|
+
targetLabel: inst.targetLabel
|
|
814
|
+
}
|
|
815
|
+
];
|
|
816
|
+
const labelInstructions = this.story.labels[this.state.currentLabel] ?? [];
|
|
817
|
+
while (this.state.instructionPointer < labelInstructions.length) {
|
|
818
|
+
const nextInst = labelInstructions[this.state.instructionPointer];
|
|
819
|
+
if (!nextInst || nextInst.type !== 'hotspot')
|
|
820
|
+
break;
|
|
821
|
+
this.state = {
|
|
822
|
+
...this.state,
|
|
823
|
+
instructionPointer: this.state.instructionPointer + 1
|
|
824
|
+
};
|
|
825
|
+
collected.push({
|
|
826
|
+
id: nextInst.id,
|
|
827
|
+
x: nextInst.x,
|
|
828
|
+
y: nextInst.y,
|
|
829
|
+
w: nextInst.w,
|
|
830
|
+
h: nextInst.h,
|
|
831
|
+
targetLabel: nextInst.targetLabel
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
this.recordTrace(`HOTSPOTS [${collected.map((h) => h.id).join(', ')}]`);
|
|
835
|
+
this.state = {
|
|
836
|
+
...this.state,
|
|
837
|
+
hotspots: collected,
|
|
838
|
+
choices: null,
|
|
839
|
+
pendingPauseMs: null,
|
|
840
|
+
isWaitingForInput: true
|
|
841
|
+
};
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
case 'layer': {
|
|
845
|
+
const name = sanitizeCssToken(inst.name) || 'master';
|
|
846
|
+
this.recordTrace(`LAYER ${name}`);
|
|
847
|
+
this.state = {
|
|
848
|
+
...this.state,
|
|
849
|
+
visual: {
|
|
850
|
+
...this.state.visual,
|
|
851
|
+
defaultLayer: name
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
case 'animate': {
|
|
857
|
+
const existing = this.state.visual.characters[inst.character];
|
|
858
|
+
if (!existing) {
|
|
859
|
+
this.recordTrace(`ANIMATE skipped (missing ${inst.character})`);
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
const animName = sanitizeCssToken(inst.animation) || 'fade-in';
|
|
863
|
+
this.recordTrace(`ANIMATE ${inst.character} ${animName}`);
|
|
864
|
+
this.state = {
|
|
865
|
+
...this.state,
|
|
866
|
+
visual: {
|
|
867
|
+
...this.state.visual,
|
|
868
|
+
characters: {
|
|
869
|
+
...this.state.visual.characters,
|
|
870
|
+
[inst.character]: {
|
|
871
|
+
...existing,
|
|
872
|
+
cssAnimation: {
|
|
873
|
+
name: animName,
|
|
874
|
+
durationMs: inst.durationMs,
|
|
875
|
+
token: Date.now()
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
case 'unlock': {
|
|
884
|
+
const catalog = this.story.achievements?.find((a) => a.id === inst.id);
|
|
885
|
+
const entry = {
|
|
886
|
+
id: inst.id,
|
|
887
|
+
title: inst.title ?? catalog?.title ?? inst.id,
|
|
888
|
+
description: inst.description ?? catalog?.description,
|
|
889
|
+
unlockedAt: Date.now()
|
|
890
|
+
};
|
|
891
|
+
this.recordTrace(`UNLOCK ${inst.id}`);
|
|
892
|
+
this.state = {
|
|
893
|
+
...this.state,
|
|
894
|
+
achievements: {
|
|
895
|
+
...this.state.achievements,
|
|
896
|
+
[inst.id]: entry
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
this.persistAchievement(entry);
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
case 'lang': {
|
|
903
|
+
const code = inst.code.trim().toLowerCase() || 'en';
|
|
904
|
+
this.recordTrace(`LANG ${code}`);
|
|
905
|
+
this.state = {
|
|
906
|
+
...this.state,
|
|
907
|
+
lang: code
|
|
908
|
+
};
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
307
911
|
case 'return': {
|
|
308
912
|
if (this.state.callStack.length > 0) {
|
|
309
913
|
const topFrame = this.state.callStack[this.state.callStack.length - 1];
|
|
914
|
+
this.recordTrace(`RETURN -> ${topFrame.returnLabel}:${topFrame.returnPointer}`);
|
|
310
915
|
this.state = {
|
|
311
916
|
...this.state,
|
|
312
917
|
currentLabel: topFrame.returnLabel,
|
|
@@ -315,6 +920,7 @@ export class StoryVM {
|
|
|
315
920
|
};
|
|
316
921
|
}
|
|
317
922
|
else {
|
|
923
|
+
this.recordTrace('END');
|
|
318
924
|
this.state = {
|
|
319
925
|
...this.state,
|
|
320
926
|
isFinished: true,
|
|
@@ -329,14 +935,14 @@ export class StoryVM {
|
|
|
329
935
|
return {
|
|
330
936
|
id: `snap_${Date.now()}_${this.snapshotStack.length}`,
|
|
331
937
|
timestamp: Date.now(),
|
|
332
|
-
state: cloneState(this.state)
|
|
938
|
+
state: cloneState(this.state),
|
|
939
|
+
historyLength: this.historyManager.getLength()
|
|
333
940
|
};
|
|
334
941
|
}
|
|
335
942
|
recordSnapshot() {
|
|
336
943
|
const snap = this.captureSnapshot();
|
|
337
944
|
this.snapshotStack.push(snap);
|
|
338
|
-
|
|
339
|
-
if (this.snapshotStack.length > 50) {
|
|
945
|
+
if (this.snapshotStack.length > this.maxSnapshots) {
|
|
340
946
|
this.snapshotStack.shift();
|
|
341
947
|
}
|
|
342
948
|
}
|
|
@@ -351,5 +957,65 @@ export class StoryVM {
|
|
|
351
957
|
listener(event);
|
|
352
958
|
}
|
|
353
959
|
}
|
|
960
|
+
persistAchievement(entry) {
|
|
961
|
+
if (typeof localStorage === 'undefined')
|
|
962
|
+
return;
|
|
963
|
+
try {
|
|
964
|
+
const key = `kawaijs_achievements_${this.storyHash}`;
|
|
965
|
+
const raw = localStorage.getItem(key);
|
|
966
|
+
const map = raw ? JSON.parse(raw) : {};
|
|
967
|
+
map[entry.id] = entry;
|
|
968
|
+
localStorage.setItem(key, JSON.stringify(map));
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// ignore quota / private mode
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/** Merge locally persisted achievements into the current state (call after start/load). */
|
|
975
|
+
hydrateAchievements() {
|
|
976
|
+
if (typeof localStorage === 'undefined')
|
|
977
|
+
return;
|
|
978
|
+
try {
|
|
979
|
+
const key = `kawaijs_achievements_${this.storyHash}`;
|
|
980
|
+
const raw = localStorage.getItem(key);
|
|
981
|
+
if (!raw)
|
|
982
|
+
return;
|
|
983
|
+
const map = JSON.parse(raw);
|
|
984
|
+
this.state = {
|
|
985
|
+
...this.state,
|
|
986
|
+
achievements: {
|
|
987
|
+
...map,
|
|
988
|
+
...this.state.achievements
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
this.notifyStateChanged();
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// ignore
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/** Export achievements as a plain JSON-serializable object (itch / analytics). */
|
|
998
|
+
exportAchievementsJson() {
|
|
999
|
+
return {
|
|
1000
|
+
storyHash: this.storyHash,
|
|
1001
|
+
exportedAt: Date.now(),
|
|
1002
|
+
achievements: Object.values(this.state.achievements)
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/** Set active language for `{t:key}` lookups (also used by `?lang=`). */
|
|
1006
|
+
setLang(code) {
|
|
1007
|
+
const normalized = code.trim().toLowerCase() || 'en';
|
|
1008
|
+
this.state = {
|
|
1009
|
+
...this.state,
|
|
1010
|
+
lang: normalized
|
|
1011
|
+
};
|
|
1012
|
+
this.notifyStateChanged();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
function sanitizeCssToken(raw) {
|
|
1016
|
+
return String(raw ?? '')
|
|
1017
|
+
.trim()
|
|
1018
|
+
.toLowerCase()
|
|
1019
|
+
.replace(/[^a-z0-9_-]+/g, '');
|
|
354
1020
|
}
|
|
355
1021
|
//# sourceMappingURL=vm.js.map
|