actor.js
19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
// 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<string,*>=} 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<import('./assets.js').createAssetLoader>} 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<string, import('@drama/core').SlotTransform>} 上一帧 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<string, PartEntry>} 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<string,import('./index.js').SlotSkin>}|null} */ (
this.skin
)
/** @type {Record<string, import('./index.js').SlotSkin>} */
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<string,*>} */
const faceFrames = {}
entry.faceFrames = faceFrames
/** @type {Set<string>} */
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<string, import('@drama/core').SlotTransform>} 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<string,{bone:string,x?:number,y?:number,rotation?:number,zOrder?:number}>}} 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
}
}
}