renderer.js 8.75 KB
// renderer.js — createPixiRenderer:实现 @drama/core Renderer 全部方法,持有 stage / actor 注册表。
//
// 这是 core 接口的实现外壳:把每个接口方法转成对 stage / actor 的操作。
// core 那侧全是数字与字符串(SlotTransform / CameraRenderState / 字符串 / 数字),
// 本包这侧才出现 PIXI.Sprite——本文件是「翻译层」的总装配。
//
// addActor / removeActor / loadScene 作为 Renderer 的**扩展方法**挂在返回对象上
// (scheduler 的 cast 出场 / transition 换班底调用),委托 stage(render-pixi §4)。

import { createAssetLoader } from './assets.js'
import { PixiStage } from './stage.js'
import { PixiActor } from './actor.js'

/**
 * 创建满足 @drama/core Renderer 接口的 PixiJS 渲染后端。
 * @param {import('pixi.js').Application} app  已 init 的 Pixi Application
 * @param {import('./index.js').RendererOpts} opts
 * @returns {import('./index.js').PixiRenderer}
 */
export function createPixiRenderer(app, opts) {
  const o = opts || /** @type {*} */ ({})
  const { PIXI, rig } = o
  // 工厂入口自检(架构 §10.3):少注入即在装配时崩,不拖到运行某行。
  if (!app) throw new Error('createPixiRenderer: app is required')
  if (!PIXI) throw new Error('createPixiRenderer: opts.PIXI is required')
  if (!rig) throw new Error('createPixiRenderer: opts.rig is required')

  const assets = createAssetLoader({ PIXI, loadTexture: o.loadTexture })
  const stage = new PixiStage({ app, PIXI, assets, resolveEffect: o.resolveEffect })
  // 道具库解析:name -> propDef({url,scale,anchor,holds}),供手持道具挂载(DramaPlayer 注入 bundle.props)。
  const resolveProp = typeof o.resolveProp === 'function' ? o.resolveProp : () => null

  /** @type {Map<string, PixiActor>} */
  const actors = new Map()

  /** @param {string} id */
  const getActor = (id) => actors.get(id) || null

  // 场景表(id -> sceneDef)+ 转场镜头回调,由装配层(usePlayback)注入。
  // transition.to 是场景 id(移植自 OLD play-demo.html:100 `getScene(t.to)`),按表解析成 def → loadScene。
  /** @type {Record<string, any>} */
  let sceneTable = {}
  /** @type {((sceneDef:any)=>void)|null} 换场后施新场景标准镜头(OLD play-demo.html:104 `cut(def.camera)`) */
  let onSceneCut = null

  // ───────────────────── Renderer 扩展方法(出场 / 换场) ─────────────────────

  /**
   * 注册一个角色到舞台(建容器、按 rig.slots 贴皮)。
   * @param {string} actorId
   * @param {import('./index.js').CharacterSkin} skin
   * @param {object} placement   {x,y,facing,scale}
   */
  function addActor(actorId, skin, placement) {
    if (actors.has(actorId)) return
    const p = /** @type {{x:number,y:number,facing:'left'|'right',scale:number}} */ (
      placement || { x: 0, y: 0, facing: 'right', scale: 1 }
    )
    const actor = new PixiActor({
      PIXI,
      rig,
      skin,
      assets,
      placement: p,
      depthScale: stage.depthScale,
    })
    stage.addActorRoot(actor.root)
    actors.set(actorId, actor)
  }

  /** @param {string} actorId */
  function removeActor(actorId) {
    const a = getActor(actorId)
    if (!a) return
    a.destroy()
    actors.delete(actorId)
  }

  /**
   * 一条龙加载场景:背景 + 世界尺寸 + 纵深 + 遮挡片。
   * @param {{background?:string,world?:{w:number,h:number},depth?:{nearY:number,farY:number,nearScale:number,farScale:number}|null,occluders?:Array<{polygon:number[][],baseY:number}>}} [def]
   */
  async function loadScene(def = {}) {
    if (def.world) stage.setWorldSize(def.world.w, def.world.h)
    stage.setDepth(def.depth || null)
    // 同步已有角色的透视。
    for (const a of actors.values()) {
      a.depthScale = stage.depthScale
      a.setWorldPos(a.worldX, a.worldY)
    }
    let tex = null
    if (def.background) tex = await assets.load(def.background) // 先就绪(遮挡片要裁它)
    if (def.background) stage.setBackgroundImage(def.background, tex)
    stage.clearOccluders()
    for (const o of def.occluders || []) stage.addOccluder(o)
  }

  // ───────────────────────── Renderer 接口(core §3.3) ─────────────────────────

  /** @type {import('@drama/core').Renderer} */
  const renderer = {
    applyActorPose(actorId, slotTransforms) {
      const a = getActor(actorId)
      if (a) a.applyActorPose(slotTransforms || {})
    },

    applyFace(actorId, emotion, talking) {
      const a = getActor(actorId)
      if (a) a.applyFace(emotion, talking, o.faceFrameDt || 16)
    },

    applyFacing(actorId, facing, headFacing) {
      const a = getActor(actorId)
      if (a) a.applyFacing(facing, headFacing)
    },

    applyCamera(state) {
      stage.applyCamera(state)
    },

    setActorWorldPos(actorId, worldX, worldY) {
      const a = getActor(actorId)
      if (a) a.setWorldPos(worldX, worldY)
    },

    moveActor(actorId, worldX, worldY, durMs, easing) {
      const a = getActor(actorId)
      if (a) a.moveTo(worldX, worldY, durMs, easing)
    },

    playEffect(name, fxOpts) {
      stage.effects.play(name, fxOpts || {})
    },

    stopEffect(name) {
      stage.effects.stop(name)
    },

    /**
     * 角色手持道具:把 name 解析成 propDef(resolveProp)后挂到该角色(actor.holdProp)。
     * @param {string} actorId @param {string} name 道具名 @param {string=} hold 握法(carry/raise)
     */
    holdProp(actorId, name, hold) {
      const a = getActor(actorId)
      const def = resolveProp(name)
      if (a && def) a.holdProp(def, hold)
    },

    /** @param {string} actorId 松开该角色手持道具。 */
    releaseProp(actorId) {
      const a = getActor(actorId)
      if (a) a.releaseProp()
    },

    resetActor(actorId) {
      const a = getActor(actorId)
      if (a) a.reset()
    },

    async runTransition(transition) {
      // 换场数据落地(移植自 OLD play-demo.html:100-104 transitionTo 的换场段):
      //   黑场动画 / 停旧场景音频 / 换班底 由 player-vue(DramaPlayer overlay)与 ctx.applyCast 负责;
      //   本包只负责「把 transition.to 解析成场景 def → loadScene(换背景/地面/遮挡/点位)→ 施新场景标准镜头」。
      /** @type {{to?:string,scene?:*,clearActors?:boolean,cast?:Array<{actorId:string,skin:*,placement:*}>}} */
      const t = transition || {}
      if (t.clearActors) {
        for (const id of [...actors.keys()]) removeActor(id)
      }
      // to=场景 id(OLD 行为):查场景表拿 def;也兼容直接传 scene 对象。
      const def = t.scene || (t.to != null ? sceneTable[t.to] : null)
      if (def) {
        await loadScene(def)
        if (onSceneCut) onSceneCut(def) // 施新场景标准镜头 cut(def.camera)(OLD play-demo.html:104)
      }
      if (Array.isArray(t.cast)) {
        for (const c of t.cast) addActor(c.actorId, c.skin, c.placement)
      }
    },
  }

  // 扩展方法挂上返回对象(scheduler 的 cast / transition 调用)。
  return Object.assign(renderer, {
    addActor,
    removeActor,
    loadScene,
    // 相机跟随焦点 / 当前世界坐标查询(usePlayback 的 ctx.focusLookup / getActorWorldPos 用,
    // 从 renderer 取 actor 当前真实坐标,moveTo 补间后自动是移动后的最新值——见任务清单 6)。
    getActorFocus: (/** @type {string} */ id) => {
      const a = getActor(id)
      return a ? a.getFocusPoint() : null
    },
    getActorWorldPos: (/** @type {string} */ id) => {
      const a = getActor(id)
      return a ? { x: a.worldX, y: a.worldY } : null
    },
    // 转场场景表 + 标准镜头回调注入(transition.to 解析用)。
    setSceneTable: (/** @type {Record<string,any>} */ t) => {
      sceneTable = t || {}
    },
    setSceneCut: (/** @type {((def:any)=>void)|null} */ fn) => {
      onSceneCut = fn || null
    },
    // 内部访问(player-vue 装配 ticker 时驱动特效轮播 + 走位补间;非 core 接口)。
    _stage: stage,
    _actors: actors,
    updateEffects: (/** @type {number} */ dtMs) => stage.effects.update(dtMs),
    // 每帧推进所有角色走位补间(移植自 OLD:每帧 actor.update 里 lerp 位置)。
    updateMoves: (/** @type {number} */ dtMs) => {
      for (const a of actors.values()) a.updateMove(dtMs)
    },
    // 每帧推进所有角色口型(说话态 talkFps 轮播;移植自 OLD actor.update 的表情段,任务清单 11)。
    updateFaces: (/** @type {number} */ dtMs) => {
      for (const a of actors.values()) a.tickFace(dtMs)
    },
    // 停所有屏幕特效(转场清场用)。
    stopAllEffects: () => stage.effects.stopAll(),
  })
}