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:
1 parent
94ebbac238
commit
fb58cd18f7
5 files changed
+368
-10
No files matched your search
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user