- 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>
129 lines
3.9 KiB
TypeScript
129 lines
3.9 KiB
TypeScript
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 })
|
|
}
|