storymode-cli 1.1.2 → 1.2.1
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 +34 -38
- package/package.json +1 -1
- package/src/agent.mjs +282 -0
- package/src/browse.mjs +91 -7
- package/src/cache.mjs +189 -0
- package/src/cli.mjs +179 -54
- package/src/config.mjs +104 -0
- package/src/hook.mjs +70 -0
- package/src/hooks.mjs +150 -0
- package/src/mcp.mjs +10 -7
- package/src/player.mjs +436 -0
- package/src/reactive.mjs +442 -0
package/src/reactive.mjs
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive companion: event mapping, narrator, state engine, animation loader.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Animations that play once then return to idle */
|
|
6
|
+
export const ONE_SHOT_ANIMS = new Set([
|
|
7
|
+
'waving hello',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** Default idle animation name */
|
|
11
|
+
export const IDLE_ANIM = 'idle breathing';
|
|
12
|
+
|
|
13
|
+
/** Sleep animation name */
|
|
14
|
+
export const SLEEP_ANIM = 'falling asleep';
|
|
15
|
+
|
|
16
|
+
/** Seconds of inactivity before sleep */
|
|
17
|
+
export const SLEEP_TIMEOUT_S = 120;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Static map: hook event → animation name + type.
|
|
21
|
+
* Key format matches STORYMODE_HOOK_EVENT env var values.
|
|
22
|
+
*/
|
|
23
|
+
export const STATIC_MAP = {
|
|
24
|
+
SessionStart: { anim: 'waving hello', type: 'one-shot' },
|
|
25
|
+
UserPromptSubmit: { anim: 'scratching head while thinking', type: 'loop' },
|
|
26
|
+
PostToolUse_action: { anim: 'casting lightning spell', type: 'loop' },
|
|
27
|
+
PostToolUse_research: { anim: 'scratching head while thinking', type: 'loop' },
|
|
28
|
+
PostToolUseFailure: { anim: 'charging up magical energy', type: 'loop' },
|
|
29
|
+
Stop: { anim: 'idle breathing', type: 'loop' },
|
|
30
|
+
SessionEnd: { anim: 'waving hello', type: 'one-shot' },
|
|
31
|
+
SubagentStart: { anim: 'charging up magical energy', type: 'one-shot' },
|
|
32
|
+
SubagentStop: { anim: 'idle breathing', type: 'loop' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Speech lines per event type. A random one is picked each time.
|
|
37
|
+
* null = no speech (stay quiet).
|
|
38
|
+
*/
|
|
39
|
+
export const SPEECH_MAP = {
|
|
40
|
+
SessionStart: ['Hey there!', 'Ready to code!', "Let's go~", 'Hi hi!', 'Oh, hello!'],
|
|
41
|
+
UserPromptSubmit: ['Hmm...', 'Interesting...', 'Ooh, a challenge!', 'Let me think...', null, null],
|
|
42
|
+
PostToolUse_action: ['Nice!', 'Zap!', 'Code go brrr~', null, null, null],
|
|
43
|
+
PostToolUse_research: ['Looking...', 'Where is it...', null, null, null, null],
|
|
44
|
+
PostToolUseFailure: ['Oof!', 'That stings...', "We'll fix it!", 'Ouch!', 'Ow ow ow'],
|
|
45
|
+
Stop: ['Done!', 'All yours~', null, null],
|
|
46
|
+
SessionEnd: ['Bye bye!', 'See ya~', 'Good session!'],
|
|
47
|
+
SubagentStart: ['Reinforcements!', 'A friend!', 'Backup arriving~'],
|
|
48
|
+
SubagentStop: ['And they vanish...', 'Back to us~', null],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Pick a random speech line for an event, avoiding the last spoken line */
|
|
52
|
+
export function pickSpeech(eventType, lastSpeech) {
|
|
53
|
+
const lines = SPEECH_MAP[eventType];
|
|
54
|
+
if (!lines || lines.length === 0) return null;
|
|
55
|
+
// Try up to 3 times to avoid repeating
|
|
56
|
+
for (let i = 0; i < 3; i++) {
|
|
57
|
+
const pick = lines[Math.floor(Math.random() * lines.length)];
|
|
58
|
+
if (pick !== lastSpeech) return pick;
|
|
59
|
+
}
|
|
60
|
+
return lines[Math.floor(Math.random() * lines.length)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Instant-layer lookup: event string → { anim, type } or null.
|
|
65
|
+
* Falls back gracefully if the animation doesn't exist in the loaded set.
|
|
66
|
+
*/
|
|
67
|
+
export function mapEventToAnimation(eventType, availableAnims) {
|
|
68
|
+
const mapping = STATIC_MAP[eventType];
|
|
69
|
+
if (!mapping) return null;
|
|
70
|
+
// If the target animation isn't available, fall back to idle
|
|
71
|
+
if (availableAnims && !availableAnims.has(mapping.anim)) {
|
|
72
|
+
if (mapping.anim === IDLE_ANIM) return null; // already idle
|
|
73
|
+
return { anim: IDLE_ANIM, type: 'loop' };
|
|
74
|
+
}
|
|
75
|
+
return mapping;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Narrate a raw hook event into a 1-line human-readable summary.
|
|
80
|
+
* Used for future LLM context (Phase 2).
|
|
81
|
+
*/
|
|
82
|
+
export function narrateEvent(msg) {
|
|
83
|
+
const { event } = msg;
|
|
84
|
+
|
|
85
|
+
if (event === 'SessionStart') return 'Session started';
|
|
86
|
+
if (event === 'SessionEnd') return 'Session ended';
|
|
87
|
+
if (event === 'SubagentStart') return 'Sub-agent spawned';
|
|
88
|
+
if (event === 'SubagentStop') return 'Sub-agent finished';
|
|
89
|
+
if (event === 'Stop') return 'Claude stopped generating';
|
|
90
|
+
|
|
91
|
+
if (event === 'UserPromptSubmit') {
|
|
92
|
+
const prompt = msg.prompt || msg.tool_input?.prompt;
|
|
93
|
+
if (prompt) {
|
|
94
|
+
const short = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt;
|
|
95
|
+
return `User asked: '${short}'`;
|
|
96
|
+
}
|
|
97
|
+
return 'User submitted a prompt';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (event === 'PostToolUseFailure') {
|
|
101
|
+
const tool = msg.tool_name || msg.tool || 'unknown tool';
|
|
102
|
+
return `${tool} failed`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// PostToolUse_action or PostToolUse_research
|
|
106
|
+
if (event?.startsWith('PostToolUse')) {
|
|
107
|
+
const tool = msg.tool_name || msg.tool || '';
|
|
108
|
+
const input = msg.tool_input || {};
|
|
109
|
+
|
|
110
|
+
if (tool === 'Bash') {
|
|
111
|
+
const cmd = input.command || '';
|
|
112
|
+
const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
|
|
113
|
+
return `Ran \`${short}\``;
|
|
114
|
+
}
|
|
115
|
+
if (tool === 'Edit' || tool === 'Write') {
|
|
116
|
+
const file = (input.file_path || '').split('/').pop() || 'a file';
|
|
117
|
+
return `Edited ${file}`;
|
|
118
|
+
}
|
|
119
|
+
if (tool === 'Read') {
|
|
120
|
+
const file = (input.file_path || '').split('/').pop() || 'a file';
|
|
121
|
+
return `Read ${file}`;
|
|
122
|
+
}
|
|
123
|
+
if (tool === 'Grep') {
|
|
124
|
+
const pattern = input.pattern || '';
|
|
125
|
+
return `Searched for '${pattern}'`;
|
|
126
|
+
}
|
|
127
|
+
if (tool === 'Glob') {
|
|
128
|
+
const pattern = input.pattern || '';
|
|
129
|
+
return `Searched files: ${pattern}`;
|
|
130
|
+
}
|
|
131
|
+
return `Used ${tool}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `Event: ${event || 'unknown'}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Session context — tracks accumulated stats for LLM context (Phase 2).
|
|
139
|
+
*/
|
|
140
|
+
export class SessionContext {
|
|
141
|
+
constructor() {
|
|
142
|
+
this.startedAt = Date.now();
|
|
143
|
+
this.toolCount = 0;
|
|
144
|
+
this.errorCount = 0;
|
|
145
|
+
this.filesTouched = new Set();
|
|
146
|
+
this.currentActivity = 'idle';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
update(msg) {
|
|
150
|
+
const { event } = msg;
|
|
151
|
+
if (event?.startsWith('PostToolUse')) {
|
|
152
|
+
this.toolCount++;
|
|
153
|
+
const file = msg.tool_input?.file_path;
|
|
154
|
+
if (file) this.filesTouched.add(file.split('/').pop());
|
|
155
|
+
this.currentActivity = event.includes('research') ? 'researching' : 'coding';
|
|
156
|
+
}
|
|
157
|
+
if (event === 'PostToolUseFailure') {
|
|
158
|
+
this.errorCount++;
|
|
159
|
+
this.currentActivity = 'debugging';
|
|
160
|
+
}
|
|
161
|
+
if (event === 'UserPromptSubmit') {
|
|
162
|
+
this.currentActivity = 'thinking';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
toJSON() {
|
|
167
|
+
return {
|
|
168
|
+
duration_s: Math.round((Date.now() - this.startedAt) / 1000),
|
|
169
|
+
tool_count: this.toolCount,
|
|
170
|
+
error_count: this.errorCount,
|
|
171
|
+
files_touched: [...this.filesTouched].slice(-10),
|
|
172
|
+
current_activity: this.currentActivity,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- State Engine ---
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Parse prose descriptions for rate keywords.
|
|
181
|
+
* Returns a magnitude (0-1 scale per event/tick).
|
|
182
|
+
*/
|
|
183
|
+
function parseRate(prose) {
|
|
184
|
+
if (!prose) return 0.06;
|
|
185
|
+
const lower = prose.toLowerCase();
|
|
186
|
+
if (/spike|hard|enormous|catastrophic|dramatic/.test(lower)) return 0.18;
|
|
187
|
+
if (/fast|quick|rapid|immediate/.test(lower)) return 0.12;
|
|
188
|
+
if (/slow|glacial|barely|steady|small/.test(lower)) return 0.03;
|
|
189
|
+
if (/moderate|gradual/.test(lower)) return 0.06;
|
|
190
|
+
return 0.06;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Keyword → category mapping for trigger extraction.
|
|
195
|
+
*/
|
|
196
|
+
const TRIGGER_KEYWORDS = [
|
|
197
|
+
[/\b(?:error|fail|wrong|broke|stings)\b/, 'error'],
|
|
198
|
+
[/\b(?:success|complete|done|finish|overcome|solved)\b/, 'success'],
|
|
199
|
+
[/\b(?:idle|silence|bored|quiet|nothing|waiting|still)\b/, 'idle'],
|
|
200
|
+
[/\b(?:over time|constant|always|even when|exhaust|deplete)\b/, 'time'],
|
|
201
|
+
[/\b(?:any event|anything|everything|any activity|always happen|wake)\b/, 'any'],
|
|
202
|
+
[/\b(?:novel|interesting|curious)\b/, 'novel'],
|
|
203
|
+
[/\b(?:sloppy|messy|hasty|rush)\b/, 'sloppy'],
|
|
204
|
+
[/\b(?:craft|clean|thought|careful|proper|organiz)/, 'craft'],
|
|
205
|
+
[/\b(?:praise|liked|respect)\b/, 'praise'],
|
|
206
|
+
[/\b(?:repeat|again|compound|stack|pile|sustain|streak)\b/, 'repeat'],
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Parse prose descriptions for event-type triggers.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} prose
|
|
213
|
+
* @param {'drain'|'restore'} fieldType - Which field this prose comes from
|
|
214
|
+
* @returns {{ positive: string[], negative: string[] }}
|
|
215
|
+
* positive: triggers that act in the field's expected direction
|
|
216
|
+
* negative: triggers in the OPPOSITE direction (e.g. "clean refactors lower it" in a restore field)
|
|
217
|
+
*/
|
|
218
|
+
function parseTriggers(prose, fieldType) {
|
|
219
|
+
if (!prose) return { positive: [], negative: [] };
|
|
220
|
+
|
|
221
|
+
// Split into clauses on sentence boundaries
|
|
222
|
+
const clauses = prose.toLowerCase().split(/[.;!]\s*|\s+but\s+/);
|
|
223
|
+
const positive = new Set();
|
|
224
|
+
const negative = new Set();
|
|
225
|
+
|
|
226
|
+
for (const clause of clauses) {
|
|
227
|
+
if (!clause.trim()) continue;
|
|
228
|
+
|
|
229
|
+
// Only detect contradictions in restore fields:
|
|
230
|
+
// "clean refactors lower it" in a restore field → clean is actually a drain trigger
|
|
231
|
+
// In drain fields, decrease-words just confirm the field direction, not negate it
|
|
232
|
+
const isContradiction = fieldType === 'restore'
|
|
233
|
+
&& /\blower|reduce|drop|decrease|diminish\b/.test(clause);
|
|
234
|
+
|
|
235
|
+
for (const [pattern, category] of TRIGGER_KEYWORDS) {
|
|
236
|
+
if (pattern.test(clause)) {
|
|
237
|
+
if (isContradiction) {
|
|
238
|
+
negative.add(category);
|
|
239
|
+
} else {
|
|
240
|
+
positive.add(category);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { positive: [...positive], negative: [...negative] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Parse a "when" condition like "< 0.15" or "> 0.8" into a test function.
|
|
251
|
+
*/
|
|
252
|
+
function parseCondition(condStr) {
|
|
253
|
+
const match = condStr.match(/^\s*(>=?|<=?)\s*([\d.]+)\s*$/);
|
|
254
|
+
if (!match) return null;
|
|
255
|
+
const op = match[1];
|
|
256
|
+
const val = parseFloat(match[2]);
|
|
257
|
+
if (op === '<') return (v) => v < val;
|
|
258
|
+
if (op === '<=') return (v) => v <= val;
|
|
259
|
+
if (op === '>') return (v) => v > val;
|
|
260
|
+
if (op === '>=') return (v) => v >= val;
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Categorize a raw hook event into abstract categories for state matching.
|
|
266
|
+
* Categories match the keywords extracted by parseTriggers().
|
|
267
|
+
*/
|
|
268
|
+
function categorizeEvent(eventType) {
|
|
269
|
+
const cats = new Set();
|
|
270
|
+
cats.add('any');
|
|
271
|
+
if (eventType === 'PostToolUseFailure') {
|
|
272
|
+
cats.add('error');
|
|
273
|
+
cats.add('sloppy'); // errors can be seen as sloppy work
|
|
274
|
+
} else if (eventType === 'Stop') {
|
|
275
|
+
cats.add('success');
|
|
276
|
+
cats.add('craft');
|
|
277
|
+
} else if (eventType?.startsWith('PostToolUse')) {
|
|
278
|
+
cats.add('success');
|
|
279
|
+
cats.add('activity');
|
|
280
|
+
cats.add('craft');
|
|
281
|
+
} else if (eventType === 'UserPromptSubmit') {
|
|
282
|
+
cats.add('novel');
|
|
283
|
+
cats.add('activity');
|
|
284
|
+
} else if (eventType === 'SessionStart') {
|
|
285
|
+
cats.add('success');
|
|
286
|
+
}
|
|
287
|
+
return cats;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* State engine — tracks internal dimensions over time based on disposition.
|
|
292
|
+
*
|
|
293
|
+
* @param {object} stateConfig - disposition.state from personality.json
|
|
294
|
+
* e.g. { energy: { initial: 0.8, drain: "...", restore: "..." }, ... }
|
|
295
|
+
*/
|
|
296
|
+
export class StateEngine {
|
|
297
|
+
constructor(stateConfig) {
|
|
298
|
+
this.values = {};
|
|
299
|
+
this.config = {};
|
|
300
|
+
this.lastTickTime = Date.now();
|
|
301
|
+
this.consecutiveErrors = 0;
|
|
302
|
+
this.consecutiveSuccesses = 0;
|
|
303
|
+
|
|
304
|
+
if (!stateConfig) return;
|
|
305
|
+
|
|
306
|
+
for (const [name, cfg] of Object.entries(stateConfig)) {
|
|
307
|
+
this.values[name] = cfg.initial ?? 0.5;
|
|
308
|
+
|
|
309
|
+
const drainParsed = parseTriggers(cfg.drain, 'drain');
|
|
310
|
+
const restoreParsed = parseTriggers(cfg.restore, 'restore');
|
|
311
|
+
|
|
312
|
+
this.config[name] = {
|
|
313
|
+
drainRate: parseRate(cfg.drain),
|
|
314
|
+
restoreRate: parseRate(cfg.restore),
|
|
315
|
+
// Drain triggers: positive from drain field + negated from restore field
|
|
316
|
+
drainTriggers: [...drainParsed.positive, ...restoreParsed.negative],
|
|
317
|
+
// Restore triggers: positive from restore field + negated from drain field
|
|
318
|
+
restoreTriggers: [...restoreParsed.positive, ...drainParsed.negative],
|
|
319
|
+
// Time-based drain flag
|
|
320
|
+
hasTimeDrain: drainParsed.positive.includes('time') || drainParsed.positive.includes('idle'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Returns true if this engine has any dimensions to track. */
|
|
326
|
+
get active() {
|
|
327
|
+
return Object.keys(this.config).length > 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Update state based on an event.
|
|
332
|
+
* @param {string} eventType - Raw hook event type (e.g. 'PostToolUseFailure')
|
|
333
|
+
*/
|
|
334
|
+
update(eventType) {
|
|
335
|
+
if (!this.active) return;
|
|
336
|
+
|
|
337
|
+
// Track streaks
|
|
338
|
+
if (eventType === 'PostToolUseFailure') {
|
|
339
|
+
this.consecutiveErrors++;
|
|
340
|
+
this.consecutiveSuccesses = 0;
|
|
341
|
+
} else if (eventType?.startsWith('PostToolUse') || eventType === 'Stop') {
|
|
342
|
+
this.consecutiveSuccesses++;
|
|
343
|
+
this.consecutiveErrors = 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const cats = categorizeEvent(eventType);
|
|
347
|
+
const repeatMultiplier = cats.has('error')
|
|
348
|
+
? Math.min(1 + this.consecutiveErrors * 0.3, 2.5)
|
|
349
|
+
: cats.has('success')
|
|
350
|
+
? Math.min(1 + this.consecutiveSuccesses * 0.2, 2.0)
|
|
351
|
+
: 1;
|
|
352
|
+
|
|
353
|
+
for (const [name, cfg] of Object.entries(this.config)) {
|
|
354
|
+
let delta = 0;
|
|
355
|
+
|
|
356
|
+
// Check restore triggers (things that INCREASE this dimension)
|
|
357
|
+
const restoreMatch = cfg.restoreTriggers.some(t => cats.has(t));
|
|
358
|
+
if (restoreMatch) {
|
|
359
|
+
delta += cfg.restoreRate * repeatMultiplier;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check drain triggers (things that DECREASE this dimension), excluding time-based
|
|
363
|
+
const drainMatch = cfg.drainTriggers.some(t => cats.has(t) && t !== 'time' && t !== 'idle');
|
|
364
|
+
if (drainMatch) {
|
|
365
|
+
delta -= cfg.drainRate * repeatMultiplier;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.values[name] = Math.max(0, Math.min(1, this.values[name] + delta));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Time-based tick — applies passive drain/restore.
|
|
374
|
+
* Call this periodically (e.g. every 5 seconds).
|
|
375
|
+
*/
|
|
376
|
+
tick() {
|
|
377
|
+
if (!this.active) return;
|
|
378
|
+
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
const elapsed = (now - this.lastTickTime) / 1000;
|
|
381
|
+
this.lastTickTime = now;
|
|
382
|
+
|
|
383
|
+
// Scale changes by time elapsed (normalized to 10s intervals)
|
|
384
|
+
const scale = elapsed / 10;
|
|
385
|
+
|
|
386
|
+
for (const [name, cfg] of Object.entries(this.config)) {
|
|
387
|
+
let delta = 0;
|
|
388
|
+
|
|
389
|
+
// Time-based drain
|
|
390
|
+
if (cfg.hasTimeDrain) {
|
|
391
|
+
delta -= cfg.drainRate * scale;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Passive decay toward 0 for dimensions without event-based drain triggers.
|
|
395
|
+
// Uses the parsed drain rate so "evaporates quickly" decays faster than "slow decay."
|
|
396
|
+
if (!cfg.hasTimeDrain && cfg.drainTriggers.length === 0 && this.values[name] > 0) {
|
|
397
|
+
delta -= cfg.drainRate * scale;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (delta !== 0) {
|
|
401
|
+
this.values[name] = Math.max(0, Math.min(1, this.values[name] + delta));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Check animation `when` conditions against current state.
|
|
408
|
+
* Returns the first matching animation role, or null.
|
|
409
|
+
*
|
|
410
|
+
* @param {object} animations - animations section from personality.json
|
|
411
|
+
* Structure: { set: { role: { name, type, when? } } }
|
|
412
|
+
* @returns {{ animName: string, type: string } | null}
|
|
413
|
+
*/
|
|
414
|
+
checkThresholds(animations) {
|
|
415
|
+
if (!this.active || !animations) return null;
|
|
416
|
+
|
|
417
|
+
for (const set of Object.values(animations)) {
|
|
418
|
+
for (const [, entry] of Object.entries(set)) {
|
|
419
|
+
if (!entry.when) continue;
|
|
420
|
+
let allMatch = true;
|
|
421
|
+
for (const [dim, condStr] of Object.entries(entry.when)) {
|
|
422
|
+
if (!(dim in this.values)) { allMatch = false; break; }
|
|
423
|
+
const test = parseCondition(condStr);
|
|
424
|
+
if (!test || !test(this.values[dim])) { allMatch = false; break; }
|
|
425
|
+
}
|
|
426
|
+
if (allMatch) {
|
|
427
|
+
return { animName: entry.name, type: entry.type || 'loop' };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Current state as a plain object. */
|
|
435
|
+
toJSON() {
|
|
436
|
+
const out = {};
|
|
437
|
+
for (const [k, v] of Object.entries(this.values)) {
|
|
438
|
+
out[k] = Math.round(v * 100) / 100;
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
}
|