macro-clips slice 8: script DSL parser + runtime label resolution

- src/lib/scriptDsl.ts: small line-based parser producing Action[] plus
  a list of ParseError{line,message}. Handles: play/pause/rewind,
  jump <sec> | #<frame> | @<marker>, state btn:<label> <state>,
  enable/disable btn:<label>, // comments, skips blanks
- evalAction for type:'script' parses the source and recursively
  dispatches sub-actions; errors surface via rt.warn
- ActionRuntime.setButtonState/setButtonDisabled are documented as
  taking an instance id OR a label; the store's actionRuntime builds
  a label→id map and resolves refs before applying
- exportPlayer template gets a matching parseScriptSource plus
  resolveButtonRef; evalAction-in-engine mirrors the TS version
- RUNTIME_VERSION bumped to '4'
- tests: parser covers commands, comments, jump variants, state/enable
  errors, unknown commands; two integration tests verify that script
  actions dispatch via evalAction and that parse errors don't block
  valid actions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 21:20:15 +02:00
1 parent 94ebbac238
commit fb58cd18f7
5 files changed
+368 -10

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
import type { Action, ButtonState } from '@/types/scene'
export interface ParseError {
line: number
message: string
}
export interface ParseResult {
actions: Action[]
errors: ParseError[]
}
const BUTTON_STATES = new Set<ButtonState>([
'normal',
'hover',
'pressed',
'disabled',
])
/**
* Parse a small line-based DSL into Action[]. Comments start with `//`
* and each non-empty line is one command. Known commands:
*
* play | pause | rewind
* jump <seconds> (e.g. `jump 2.5`)
* jump #<frame> (e.g. `jump #135`)
* jump @<marker> (e.g. `jump @intro`)
* state btn:<label> <state> (state ∈ normal|hover|pressed|disabled)
* enable btn:<label>
* disable btn:<label>
*
* Button labels are left as-is in `instanceId`. The runtime resolves
* labels to real instance IDs at eval time.
*/
export function parseScript(source: string): ParseResult {
const actions: Action[] = []
const errors: ParseError[] = []
const lines = source.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const stripped = stripComment(lines[i]).trim()
if (!stripped) continue
const res = parseLine(stripped)
if (res.error) errors.push({ line: i + 1, message: res.error })
else if (res.action) actions.push(res.action)
}
return { actions, errors }
}
function stripComment(line: string): string {
const i = line.indexOf('//')
return i < 0 ? line : line.slice(0, i)
}
function parseLine(line: string): { action?: Action; error?: string } {
const tokens = line.split(/\s+/).filter(Boolean)
if (!tokens.length) return {}
const cmd = tokens[0].toLowerCase()
switch (cmd) {
case 'play':
return ok({ type: 'play' })
case 'pause':
return ok({ type: 'pause' })
case 'rewind':
return ok({ type: 'rewindToLoop' })
case 'jump':
return parseJump(tokens)
case 'state':
return parseState(tokens)
case 'enable':
return parseEnable(tokens, false)
case 'disable':
return parseEnable(tokens, true)
default:
return { error: `unknown command "${cmd}"` }
}
}
function ok(action: Action): { action: Action } {
return { action }
}
function parseJump(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 2) return { error: 'jump: missing argument' }
const arg = tokens[1]
if (arg.startsWith('#')) {
const frame = Number(arg.slice(1))
if (!Number.isFinite(frame)) return { error: `jump: invalid frame "${arg}"` }
return ok({ type: 'jumpToFrame', frame })
}
if (arg.startsWith('@')) {
const name = arg.slice(1)
if (!name) return { error: 'jump: missing marker name after @' }
return ok({ type: 'jumpToMarker', name })
}
const time = Number(arg)
if (!Number.isFinite(time)) return { error: `jump: invalid time "${arg}"` }
return ok({ type: 'jumpTo', time })
}
function parseButtonRef(token: string): string | null {
if (!token.startsWith('btn:')) return null
const label = token.slice(4)
return label || null
}
function parseState(tokens: string[]): { action?: Action; error?: string } {
if (tokens.length < 3) return { error: 'state: expected `btn:<label> <state>`' }
const label = parseButtonRef(tokens[1])
if (!label) return { error: `state: expected button ref like btn:<label>, got "${tokens[1]}"` }
const state = tokens[2] as ButtonState
if (!BUTTON_STATES.has(state)) {
return { error: `state: unknown state "${state}"` }
}
return ok({ type: 'setButtonState', instanceId: label, state })
}
function parseEnable(
tokens: string[],
disabled: boolean,
): { action?: Action; error?: string } {
const verb = disabled ? 'disable' : 'enable'
if (tokens.length < 2) return { error: `${verb}: missing button ref` }
const label = parseButtonRef(tokens[1])
if (!label) {
return { error: `${verb}: expected button ref like btn:<label>` }
}
return ok({ type: 'setButtonDisabled', instanceId: label, disabled })
}