// line-handlers.js —— 各指令到三接口调用的纯映射。 // // 这里只把「一条 ScriptLine 的某个字段」翻译成「对 renderer / camera / animState 的一次调用」, // **不决定时序**(并行还是阻塞)——时序归 scheduler。本文件按 §3.4 归类: // 非阻塞、行首即发:cam / fx / sfx / pose / trigger / emotion / facing / headFacing / prop / propPlace。 // 阻塞项(moveTo / say / transition)由 scheduler 直接编排,不放这里。 // // ctx 形状(由装配层 / 测试提供,scheduler 透传): // { // renderer, audio, tts, // animStateOf(alias) => AnimationState|null, // resolvePoint(nameOrXY) => {x,y}|null, // getActorWorldPos(alias) => {x,y}|null, // focusLookup(alias) => {x,y}|null, // castVoice(alias) => { voice?, speechRate?, lang? }, // } /** * 行首即发的非阻塞副作用:把不需要 await 的指令一次性发出。 * @param {import('./types.js').ScriptLine} line * @param {{renderer:import('./types.js').Renderer, audio:import('./types.js').AudioPort, camera:(import('./types.js').Camera|null)}} ports * @param {import('./types.js').SchedulerContext} ctx * @param {import('./types.js').LineSfxState} sfxState 本行级 sfx/fx 跟踪(供行末清理 until:lineEnd) */ export function emitNonBlocking(line, ports, ctx, sfxState) { const { renderer, audio, camera } = ports const alias = line.who || null const anim = alias && ctx.animStateOf ? ctx.animStateOf(alias) : null // 独立道具放置 / 移除(不依赖人) if (line.propPlace) { const pp = /** @type {{id:string,name?:string,x?:number,y?:number,scale?:number,remove?:boolean}} */ (line.propPlace) if (pp.remove) renderer.stopEffect(`prop:${pp.id}`) else renderer.playEffect(`prop:${pp.id}`, pp) } // 屏幕特效 if (line.fxStop) renderer.stopEffect(typeof line.fx === 'object' ? line.fx.name : 'fx') if (line.fx) { const name = typeof line.fx === 'string' ? line.fx : line.fx.name const opts = typeof line.fx === 'object' ? line.fx : undefined renderer.playEffect(name, opts) } // 音效 / BGM if (line.sfxStop) audio.stopAll() if (line.sfx) { const url = typeof line.sfx === 'string' ? line.sfx : line.sfx.url const volume = typeof line.sfx === 'object' ? line.sfx.volume : undefined // 默认不循环(对齐 OLD playSfx:单次播放;要循环需显式 sfx.loop:true)。 const loop = typeof line.sfx === 'object' && line.sfx.loop === true const handle = audio.play({ url }, { volume, loop }) if (typeof line.sfx === 'object' && line.sfx.until === 'lineEnd') { sfxState.untilLineEnd = handle } } // 镜头(行首即发,不阻塞、不计入本行时长) if (line.cam && camera) camera.applyCommand(line.cam, ctx.focusLookup) // 朝向 / 头朝向:拆开互不耦合(移植自 OLD play-demo.html:203-204 + character-actor setFacing/setHeadFacing)。 // 仅当行有 line.facing 才改 body;仅当行有 line.headFacing 才改头。 // applyFacing(facing, headFacing):facing=undefined → 不动身体;headFacing=undefined → 不动头。 if (alias && line.facing) renderer.applyFacing(alias, line.facing, undefined) if (alias && line.headFacing !== undefined) { renderer.applyFacing(alias, undefined, line.headFacing) } if (alias && line.emotion) renderer.applyFace(alias, line.emotion, false) // 姿势(多轨道并行设置) if (alias && anim && line.pose) { for (const [track, clip] of Object.entries(line.pose)) { anim.setAnimation(track, clip, { mix: 200 }) } } // 触发(oneshot) if (alias && anim && line.trigger) { anim.setAnimation(line.trigger.track, line.trigger.clip, { mix: 120 }) } // 角色携带 / 放下 / 扔道具:真挂到骨骼(renderer.holdProp/releaseProp,移植自 OLD holdProp)。 if (alias && line.prop && typeof renderer.holdProp === 'function') { const prop = /** @type {{name?:string,hold?:string,drop?:boolean,throw?:boolean}} */ (line.prop) if (prop.drop) renderer.releaseProp(alias) else if (prop.throw) { if (anim) anim.setAnimation('arm_r', 'arm_r.punch', { mix: 120 }) renderer.releaseProp(alias) } else if (prop.name) { renderer.holdProp(alias, prop.name, prop.hold || 'carry') } } } /** * 无 voice 时按字数估算台词时长(移植自原型 sayDuration 思路)。 * @param {string} text * @returns {number} ms */ export function estimateSayDuration(text) { if (!text) return 0 // 对齐 OLD play-demo.html:120 `sayDuration = Math.max(900, text.length*55 + 500)`。 return Math.max(900, text.length * 55 + 500) }