// actor.js — 单角色 Pixi 容器:slot Sprite 装配 + SlotTransform 落地 + 表情帧 + 道具跟手 + 镜像。 // // 移植自原型 character-actor.js 的「落地」部分,**去掉求值**——世界矩阵求值已在 @drama/core。 // core 每帧给 applyActorPose 一个 `{ slotName: SlotTransform }`(已 decompose 的 2D 变换), // 本类只把这些数字摆到 Sprite 上;facing / 扭头镜像反射轴这类纯渲染几何留在本包(geom.js)。 import { bodyFlipSign, headMirrorSign, resolveSlotWrite, depthZIndex } from './geom.js' const HEAD_SLOTS = new Set(['head', 'face']) /** * @typedef {Object} PartEntry 一个 slot 的装配条目。 * @property {*} obj Pixi Sprite * @property {string} bone 挂载骨 * @property {*} cfg SlotSkin 配置 * @property {number} zDefault 默认层级(无 SlotTransform.zOrder 时回落) * @property {Record=} faceFrames face 部件预载帧(url->纹理) * @property {number=} poseScaleX 最近一次骨骼姿势 + face 基础 scale 后的 x 缩放 * @property {number=} poseScaleY 最近一次骨骼姿势 + face 基础 scale 后的 y 缩放 */ /** * @typedef {Object} HeldProp 一件手持道具。 * @property {*} spr * @property {{bone:string,x?:number,y?:number,rotation?:number,zOrder?:number,url?:string,scale?:number}} cfg * @property {number} scale * @property {(import('@drama/core').SlotTransform|null)} attachTransform */ export class PixiActor { /** * @param {Object} deps * @param {*} deps.PIXI Pixi 命名空间 * @param {import('@drama/rig').RigDefinition} deps.rig * @param {import('./index.js').CharacterSkin} deps.skin * @param {ReturnType} deps.assets * @param {{x:number,y:number,facing:'left'|'right',scale:number}} deps.placement * @param {((y:number)=>number)|null=} deps.depthScale 透视缩放 fn(worldY)->倍数;null=无透视 */ constructor({ PIXI, rig, skin, assets, placement, depthScale = null }) { this.PIXI = PIXI this.rig = rig /** @type {import('./index.js').CharacterSkin} */ this.skin = skin this.assets = assets /** @type {((y:number)=>number)|null} */ this.depthScale = depthScale /** @type {('left'|'right'|null)} */ this.headFacing = null this.worldX = placement.x this.worldY = placement.y this.facing = placement.facing || 'right' this.baseScale = placement.scale != null ? placement.scale : 1 this.emotion = 'normal' this.talking = false this.faceClock = 0 /** @type {({fromX:number,fromY:number,toX:number,toY:number,dur:number,t:number,easing:string}|null)} 位移补间(走位 lerp,移植自原型 character-actor.moveTo/update) */ this.moveTween = null /** @type {Map} 上一帧 head/face 反射轴用 */ this._lastPose = new Map() // 根容器:定位到世界坐标 + facing 翻转。 this.root = new PIXI.Container() this.root.position.set(this.worldX, this.worldY) // 部件层:全局 z-order(slot.zOrder / SlotSkin.zOrder)。 this.layer = new PIXI.Container() this.layer.sortableChildren = true this.root.addChild(this.layer) /** @type {Map} slotName -> entry */ this.parts = new Map() /** @type {HeldProp[]} 手持道具 */ this.heldProps = [] this._buildParts() this._applyRootScale() } /** 有效缩放 = 基础缩放 × 透视缩放(随世界 y)。 */ effScale() { const persp = this.depthScale ? this.depthScale(this.worldY) : 1 return this.baseScale * persp } _applyRootScale() { const s = this.effScale() // 贴图基准朝向=屏幕左:right 才水平翻转(geom.bodyFlipSign)。 this.root.scale.set(s * bodyFlipSign(this.facing), s) // 纵深遮挡:越靠下(worldY 越大=越近)越靠前。 this.root.zIndex = depthZIndex(this.worldY) } _buildParts() { const skin = /** @type {{parts?:Record}|null} */ ( this.skin ) /** @type {Record} */ const parts = (skin && skin.parts) || {} const builtSlots = new Set() for (const slot of this.rig.slots) { const sp = parts[slot.name] if (!sp) continue // 真实贴皮:无贴图的 slot 跳过,不画占位 builtSlots.add(slot.name) const spr = new this.PIXI.Sprite() const a = sp.anchor || { x: 0.5, y: 0.5 } if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) const z = sp.zOrder != null ? sp.zOrder : slot.zOrder spr.zIndex = z this.layer.addChild(spr) /** @type {PartEntry} */ const entry = { obj: spr, bone: slot.bone, cfg: sp, zDefault: z } if (sp.emotions) { // 表情部件:预载所有情绪的静止 / 说话帧,update 里按说话状态轮播。 /** @type {Record} */ const faceFrames = {} entry.faceFrames = faceFrames /** @type {Set} */ const urls = new Set() for (const em of Object.values(sp.emotions)) { if (em.static) urls.add(em.static) if (em.talk) urls.add(em.talk) } for (const u of urls) { this.assets.load(u).then((t) => { if (t) faceFrames[u] = t }) } // 初始表情帧:出场即显示 normal(或首个情绪)的静止帧,否则在被某行驱动 emotion 之前 // face 一直无贴图 = 空白脸(实测漏点)。 const defEmo = sp.emotions.normal || Object.values(sp.emotions)[0] const initUrl = defEmo && defEmo.static if (initUrl) { const EMPTY = this.PIXI.Texture && this.PIXI.Texture.EMPTY this.assets.load(initUrl).then((t) => { // 仅当尚未被 applyFace 驱动(贴图仍是空纹理)时设初始帧,避免覆盖已驱动的情绪。 if (t && (!EMPTY || spr.texture === EMPTY)) spr.texture = t }) } } else if (sp.url) { this.assets.load(sp.url).then((tex) => { if (tex) spr.texture = tex }) } this.parts.set(slot.name, entry) } // 额外装饰部件:旧版 stage-test 的“区域分层”会产生 hair_back / skirt_back 这类 // 不在 rig.slots 里的 part。它们必须声明 `bone`,跟随指定骨骼,但不参与骨架求值。 for (const [name, sp] of Object.entries(parts)) { if (builtSlots.has(name) || !sp || !sp.bone || !sp.url) continue const spr = new this.PIXI.Sprite() const a = sp.anchor || { x: 0.5, y: 0.5 } if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) const z = sp.zOrder != null ? sp.zOrder : 10 spr.zIndex = z this.layer.addChild(spr) /** @type {PartEntry} */ const entry = { obj: spr, bone: sp.bone, cfg: sp, zDefault: z, extra: true } this.assets.load(sp.url).then((tex) => { if (tex) spr.texture = tex }) this.parts.set(name, entry) } } // ───────────────────────── Renderer 接口落地 ───────────────────────── /** * 把每个 slot 的最终 2D 变换(core 已 decompose)落到对应 Sprite。 * @param {Record} slotTransforms */ applyActorPose(slotTransforms) { const headMirror = headMirrorSign(this.facing, this.headFacing) // 反射轴 = 头骨节点 x(头贴图绕它镜像;脸偏移也绕它镜像才不错位)。 const headT = slotTransforms.head const headBoneX = headMirror === -1 && headT ? headT.x : 0 for (const [slotName, t] of Object.entries(slotTransforms)) { const p = this.parts.get(slotName) if (!p) continue this._lastPose.set(slotName, t) const isHeadSlot = HEAD_SLOTS.has(slotName) const w = resolveSlotWrite(t, { isHeadSlot, headMirror, headBoneX }) p.obj.position.set(w.x, w.y) // 叠加 skin 的每部件静态对齐:rotation 偏置 + 贴图缩放(cfg.scale,如头 0.55)+ 镜像 // (cfg.mirror:右肢复用左图水平翻转)。缺了 cfg.scale 会导致部件按骨骼原尺寸(≈1)渲染, // 头/躯干显著过大——这是 worker/lady 头巨大的根因。 p.obj.rotation = w.rotation + ((p.cfg.rotation || 0) * Math.PI) / 180 const ps = p.cfg.scale != null ? p.cfg.scale : 1 const mir = p.cfg.mirror ? -1 : 1 p.poseScaleX = w.scaleX * ps * mir p.poseScaleY = w.scaleY * ps p.obj.scale.set(p.poseScaleX, p.poseScaleY) if (slotName === 'face') this._applyFaceScale(p, this._currentFaceDef(p)) // zOrder 覆盖部件层级(SlotTransform.zOrder > skin zDefault)。 if (t.zOrder != null) p.obj.zIndex = t.zOrder else p.obj.zIndex = p.zDefault } // 额外装饰部件跟随其 cfg.bone 指向的标准骨骼/slot,bonePos 是相对骨骼偏移。 // 这些 part 没有自己的 slotTransforms 键,所以不能走上面的标准 slot 循环。 for (const [partName, p] of this.parts) { if (!p.extra) continue const t = slotTransforms[p.bone] if (!t) continue const bp = p.cfg.bonePos || { x: 0, y: 0 } p.obj.position.set(t.x + (bp.x || 0), t.y + (bp.y || 0)) p.obj.rotation = t.rotation + ((p.cfg.rotation || 0) * Math.PI) / 180 const ps = p.cfg.scale != null ? p.cfg.scale : 1 const mir = p.cfg.mirror ? -1 : 1 p.obj.scale.set((t.scaleX || 1) * ps * mir, (t.scaleY || 1) * ps) p.obj.zIndex = p.cfg.zOrder != null ? p.cfg.zOrder : p.zDefault this._lastPose.set(partName, { ...t, x: t.x + (bp.x || 0), y: t.y + (bp.y || 0) }) } // 手持道具:跟着附着骨骼走(位置 / 旋转 / 缩放 / 层级由 cfg 决定)。 for (const h of this.heldProps) { const bt = slotTransforms[h.cfg.bone] || h.attachTransform if (!bt) continue h.spr.position.set(bt.x + (h.cfg.x || 0), bt.y + (h.cfg.y || 0)) h.spr.rotation = bt.rotation + (h.cfg.rotation || 0) * (Math.PI / 180) h.spr.scale.set((h.scale || 1) * (bt.scaleX || 1)) h.spr.zIndex = h.cfg.zOrder != null ? h.cfg.zOrder : 33 } } /** * 切表情帧;talking=true 在静止 / 张嘴两帧按 talkFps 轮播口型,false 回静止帧。 * @param {string} emotion * @param {boolean} talking * @param {number=} dtMs 推进口型时钟的帧时长(轮播用);不传则只切静止帧 */ applyFace(emotion, talking, dtMs = 0) { if (emotion) this.emotion = emotion this.talking = !!talking const face = this.parts.get('face') if (!face || !face.faceFrames) return const emo = face.cfg.emotions || {} const em = emo[this.emotion] || emo.normal || Object.values(emo)[0] if (!em) return let url = em.static if (this.talking && em.talk) { this.faceClock += dtMs const fps = face.cfg.talkFps || 7 url = Math.floor(this.faceClock / (1000 / fps)) % 2 ? em.talk : em.static } else { this.faceClock = 0 } const tex = url && face.faceFrames[url] if (tex) face.obj.texture = tex this._applyFaceScale(face, em) } /** * 每帧推进口型(仅在 talking 时轮播静止/张嘴帧,移植自原型 character-actor.js:261-276)。 * 由 renderer.updateFaces → usePlayback.update 每帧调用,使 setTalking 后口型真按 talkFps 动。 * @param {number} dtMs */ tickFace(dtMs) { if (!this.talking) return const face = this.parts.get('face') if (!face || !face.faceFrames) return const emo = face.cfg.emotions || {} const em = emo[this.emotion] || emo.normal || Object.values(emo)[0] if (!em || !em.talk) return this.faceClock += dtMs const fps = face.cfg.talkFps || 7 const url = Math.floor(this.faceClock / (1000 / fps)) % 2 ? em.talk : em.static const tex = url && face.faceFrames[url] if (tex) face.obj.texture = tex this._applyFaceScale(face, em) } /** * 当前表情定义:先取指定 emotion,再降级 normal/首个。 * @param {PartEntry} face */ _currentFaceDef(face) { const emo = face.cfg.emotions || {} return emo[this.emotion] || emo.normal || Object.values(emo)[0] || null } /** * 表情级缩放:最终脸部缩放 = face 部件 scale × emotion.scale;mirror 只改变 x 符号,不累乘。 * @param {PartEntry} face * @param {*=} em */ _applyFaceScale(face, em) { if (!face || !face.obj || !face.obj.scale) return const baseX = face.poseScaleX != null ? face.poseScaleX : face.obj.scale.x const baseY = face.poseScaleY != null ? face.poseScaleY : face.obj.scale.y const emotionScale = em && typeof em.scale === 'number' && em.scale > 0 ? em.scale : 1 const emotionMirror = em && em.mirror ? -1 : 1 face.obj.scale.set(baseX * emotionScale * emotionMirror, baseY * emotionScale) } /** * 身体水平翻转(贴图基准朝向约定)+ 头部独立扭头。 * 镜像在下一帧 applyActorPose 里随 facing/headFacing 生效(反射轴计算); * 身体翻转即时写根容器 scale。 * facing/headFacing 互不耦合(移植自 OLD setFacing 只改 body / setHeadFacing 只改头): * facing=undefined → 不动身体;headFacing=undefined → 不动头(保留当前扭头)。 * headFacing=null(显式)→ 头回到跟随身体。 * @param {('left'|'right'|undefined)} facing * @param {('left'|'right'|null|undefined)} headFacing */ applyFacing(facing, headFacing) { if (facing && facing !== this.facing) { this.facing = facing this._applyRootScale() } if (headFacing !== undefined) this.headFacing = headFacing || null // 立即对已有 head/face 姿势重算反射(不必等下一帧 pose)。 const headT = this._lastPose.get('head') const faceT = this._lastPose.get('face') const headMirror = headMirrorSign(this.facing, this.headFacing) const headBoneX = headMirror === -1 && headT ? headT.x : 0 for (const slotName of HEAD_SLOTS) { const t = this._lastPose.get(slotName) const p = this.parts.get(slotName) if (!t || !p) continue const w = resolveSlotWrite(t, { isHeadSlot: true, headMirror, headBoneX }) p.obj.position.set(w.x, w.y) const ps = p.cfg.scale != null ? p.cfg.scale : 1 const mir = p.cfg.mirror ? -1 : 1 p.poseScaleX = w.scaleX * ps * mir p.poseScaleY = w.scaleY * ps p.obj.scale.set(p.poseScaleX, p.poseScaleY) if (slotName === 'face') this._applyFaceScale(p, this._currentFaceDef(p)) } void faceT } /** * 手持道具:把 propDef 的贴图挂到指定握法(holds[hold])的骨骼上,随该骨骼世界变换走 * (applyActorPose 的 heldProps 循环落地)。移植自原型 character-actor.holdProp。 * @param {{url?:string,scale?:number,anchor?:{x:number,y:number},holds?:Record}} propDef * @param {string=} hold 握法名(carry/raise…),缺省取第一个 */ holdProp(propDef, hold) { if (!propDef) return this.releaseProp() const holds = propDef.holds || {} const h = holds[hold || ''] || holds.carry || Object.values(holds)[0] || {} const spr = new this.PIXI.Sprite() const a = propDef.anchor || { x: 0.5, y: 0.5 } if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(a.x, a.y) spr.zIndex = h.zOrder != null ? h.zOrder : 33 this.layer.addChild(spr) if (propDef.url) { this.assets.load(propDef.url).then((t) => { if (t) spr.texture = t }) } this.heldProps.push({ spr, cfg: { bone: h.bone || 'lower_arm_l', x: h.x || 0, y: h.y || 0, rotation: h.rotation || 0, zOrder: h.zOrder }, scale: propDef.scale != null ? propDef.scale : 1, attachTransform: null, }) } /** 松开/移除所有手持道具。 */ releaseProp() { for (const hp of this.heldProps) { if (hp.spr && hp.spr.parent) hp.spr.parent.removeChild(hp.spr) if (hp.spr && typeof hp.spr.destroy === 'function') hp.spr.destroy() } this.heldProps = [] } /** 设置世界位置:透视缩放与遮挡 zIndex 随 y 更新(核对原型 setWorldPos)。 */ /** @param {number} x @param {number=} y */ setWorldPos(x, y = this.worldY) { this.worldX = x this.worldY = y this.root.position.set(x, y) this._applyRootScale() } /** * 平滑移动到世界点 (x,y)。移植自原型 character-actor.js:186-189 `moveTo`。 * dur<=0 直接置终点;否则建补间,每帧由 updateMove(dt) 推进 lerp。 * @param {number} x @param {number=} y @param {number=} duration @param {string=} easing 'linear'|'easeOut'|'easeInOut' */ moveTo(x, y = this.worldY, duration = 0, easing = 'linear') { if (duration <= 0) { this.setWorldPos(x, y) this.moveTween = null return } this.moveTween = { fromX: this.worldX, fromY: this.worldY, toX: x, toY: y, dur: duration, t: 0, easing } } /** * 每帧推进位移补间(移植自原型 character-actor.js:210-220 的 update 头部)。 * 走路默认匀速(linear);easeOut=缓出停;easeInOut=两头慢。回写 worldX/worldY。 * @param {number} dtMs */ updateMove(dtMs) { if (!this.moveTween) return const m = this.moveTween m.t += dtMs const f = Math.min(m.t / m.dur, 1) const e = m.easing === 'easeOut' ? 1 - Math.pow(1 - f, 3) : m.easing === 'easeInOut' ? f < 0.5 ? 2 * f * f : 1 - Math.pow(-2 * f + 2, 2) / 2 : f this.setWorldPos(m.fromX + (m.toX - m.fromX) * e, m.fromY + (m.toY - m.fromY) * e) if (f >= 1) this.moveTween = null } /** * 相机跟随焦点(上半身高度,随透视缩放)。移植自原型 character-actor.js:198-200。 * @returns {{x:number,y:number}} */ getFocusPoint() { return { x: this.worldX, y: this.worldY - 360 * this.effScale() } } /** * 附着一件道具到指定 bone slot(道具跟手)。 * @param {{bone:string,x?:number,y?:number,rotation?:number,zOrder?:number,url?:string,scale?:number}} cfg */ attachProp(cfg) { const spr = new this.PIXI.Sprite() if (spr.anchor && typeof spr.anchor.set === 'function') spr.anchor.set(0.5, 0.5) spr.zIndex = cfg.zOrder != null ? cfg.zOrder : 33 this.layer.addChild(spr) if (cfg.url) { this.assets.load(cfg.url).then((t) => { if (t) spr.texture = t }) } /** @type {HeldProp} */ const held = { spr, cfg, scale: cfg.scale != null ? cfg.scale : 1, attachTransform: null } this.heldProps.push(held) return held } /** 松开所有道具。 */ releaseProps() { for (const h of this.heldProps) { try { h.spr.destroy() } catch (e) { void e } } this.heldProps = [] } /** 复位:表情归位、停说话、松道具。动画归位(reset)由 core 的 animState 负责。 */ reset() { this.emotion = 'normal' this.talking = false this.faceClock = 0 this.moveTween = null this.releaseProps() } destroy() { try { this.root.destroy({ children: true }) } catch (e) { void e } } }