line-handlers.js 4.61 KB
// 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)
}