feedback6: merge R21F — enemy flyers hover 1.3 tiles above the road instead of climbing each raised tile (GitHub #277, @FrogThai); enemy animation states from PR #275 by @xcdoge, client part: numbered skill clips, the Stun_1 / Dizzy_* spellings, run cycles (猎狗pro, 深池侦察犬), the rebirths of 巨大的丑东西 and 自在 (data/assets.json regenerated offline: enemies only); golden unchanged
This commit is contained in:
commit
1e58843fb4
13 files changed
+396
-30
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"b699458e3e10","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
|
||||
{"version":1,"hash":"7ae1d03466cb","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
|
||||
+5
-3
@@ -242,11 +242,13 @@ Roles = {
|
||||
attack: Clip, // play begin once, loop per attack, end when stopping; `via:'idle'` ⇒ add a flash
|
||||
attackDown: Clip|null, // _Down variants (target below the unit)
|
||||
skill: SkillClip|null, // for the chess's default skill (primary index)
|
||||
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups)
|
||||
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups); an enemy: every
|
||||
// numbered skill clip (Skill_01..04 of 盐风主教昆图斯 — the slot a sim `cast` event names, PR #275)
|
||||
die: string|null, // null ⇒ a Back model gives way to the Front model's Die (DESIGN §22.1); any other
|
||||
// skeleton holds its idle's first frame while it fades out
|
||||
move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_*
|
||||
stun: Clip|null // null ⇒ freeze the track (timeScale 0)
|
||||
run?: Clip, // the model's own Run cycle (Run_Begin/Loop/End): an enemy faster than moveSpeed 1 moves on it
|
||||
stun: Clip|null // Stun | Stun_1 | Dizzy_Loop (+ *_Begin / *_End); null ⇒ freeze the track (timeScale 0)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -299,7 +301,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
- **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up — while it stands: a dead or knocked-out operator falls and lies with the Front model unless its Back skeleton has a Die clip of its own (131 of the 135 have none; DESIGN §22.1, GitHub issue #25).
|
||||
- **Attack speed:** set the attack `timeScale` to `duration / attackInterval`.
|
||||
- **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them. Two prefab quirks on top (PR #211 by @xcdoge; the owner's decision of 2026-10-06; docs/research/12 §3.1): the two 帝国炮火先兆者 are stretched vertically (`modelScaleY` 1.263: their Graphic scale is (0.19, 0.24, 0.24)) and 木制瑞印 is mirrored (`mirrorX`: a negative Graphic X scale) — `tools/local-extract/enemy_model_offsets.py` reads them, `tools/build-data.mjs MODEL_STRETCH_Y` / `MIRRORED_PREFABS` keep them.
|
||||
- **Flying units** hover `FLY_HOVER` = 1.3 tiles above the ground they cross (render/units.js; the client's single fly offset 0.35 in its character space, whose unit is the standard prefab scale 0.27 — docs/research/12): the body, its HP bar, damage numbers and hits ride the lift, the shadow stays on the ground tile, on the 2D and the 3D board alike (one camera drives both).
|
||||
- **Flying units** hover `FLY_HOVER` = 1.3 tiles up (render/units.js; the client's single fly offset 0.35 in its character space, whose unit is the standard prefab scale 0.27 — docs/research/12) — an enemy flyer above the road whatever tile it crosses (a high-ground or forbidden block under it is no step, GitHub #277), an operator or summon above its tile: the body, its HP bar, damage numbers and hits ride the lift, the shadow stays on the ground tile (the block top under an enemy flyer crossing one), on the 2D and the 3D board alike (one camera drives both).
|
||||
- **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases:
|
||||
- `_2` variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game.
|
||||
- an enemy whose own model no dump carries: 灼热源石虫 / 炽焰源石虫 (`enemy_1305_mhslim` / `_2`) use the plain 源石虫 on
|
||||
|
||||
@@ -29,7 +29,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w
|
||||
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
|
||||
view.resize() ; view.destroy()
|
||||
```
|
||||
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
|
||||
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -331,6 +331,12 @@ explains every other per-model difference (§3.1): **the code never repositions
|
||||
3 tests: `FLY_HOVER ≈ 0.35 / 0.27`; every flyer of this mode keeps a positive, consistent net height and the two 妖怪
|
||||
drones *were* under the tile at the old 0.32 (with 帝国炮火先兆者's `bounds.y > 0` pinned as the "art above origin"
|
||||
case); and a view-level wiring test (a flying view's `hover === FLY_HOVER`, a ground view's `hover === 0`).
|
||||
- 0.2.1 (GitHub #277 by @FrogThai, 飞机经过一格方块时会跟走楼梯一样): the lift of an **enemy** flyer starts from the road.
|
||||
The view had put a flyer on the tile top under it (`groundZ`) before adding `FLY_HOVER`, so one high-ground /
|
||||
forbidden block on its way lifted it 0.42 / 0.3 tiles and dropped it again; the client's offset is one constant over
|
||||
the route, so `UnitView.sync` keeps every enemy at z 0 (ground enemies already were), operators and summons keep their
|
||||
tile top. The shadow lies on the tile under the flyer (`shadowZ`; [ASSUMED] — no official reference for a shadow over
|
||||
a block). Test: `describe('an enemy flyer crossing a raised tile keeps its height (GitHub #277)')`.
|
||||
- The model quirks (§3.1): `tools/build-data.mjs MODEL_STRETCH_Y` / `MIRRORED_PREFABS` → enemies.json `modelScaleY` /
|
||||
`mirrorX` (0.2.0 regenerated its own enemies.json offline; only those three keys changed), `render/units.js
|
||||
enemyModelScaleY` and the mirror flip; `tools/local-extract/enemy_model_offsets.py` (optional, host-side, UnityPy) reads
|
||||
|
||||
@@ -1388,6 +1388,8 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
|
||||
case 'skill': { const v = views.get(e[1]); if (v) { v.setSkill?.(!!e[2]); fx.skill(v, !!e[2]); } break; }
|
||||
// the skill slot an enemy ability casts (PR #275): the view swaps to that slot's clip (a multi-skill boss's Skill_01..04)
|
||||
case 'cast': { const v = views.get(e[1]); if (v) v.setSkillSlot?.(e[2] | 0); break; }
|
||||
case 'die': {
|
||||
const v = views.get(e[1]);
|
||||
const used = consumedIds.delete(e[1]);
|
||||
|
||||
@@ -107,6 +107,9 @@ export class SpineActor {
|
||||
this.endClip = null; // setForm's closing clip, played as a change clip once the clock reaches endAt,
|
||||
this.endAt = 0; // landing in endRoles (the next form's) when given
|
||||
this.endRoles = null;
|
||||
this.skillIndex = null; // the skill slot whose clip `skill` plays (setSkillIndex), null = the manifest's primary
|
||||
this.runMode = false; // move on the model's own Run cycle (setRunMode)
|
||||
this.formRoles = null; // the clip set of the form in force (setForm), over the unit's own roles
|
||||
this.skillOn = false;
|
||||
this.skillClipOnce = false; // skillBegin: the skill's own clip plays once (a clip with no Begin / own Idle)
|
||||
this.skillEndPending = false; // … and the skill turned off inside it: play its End clip / base when it ends
|
||||
@@ -145,12 +148,32 @@ export class SpineActor {
|
||||
* @param {number|undefined} index 0-based skill index
|
||||
*/
|
||||
setSkillIndex(index) {
|
||||
const anims = this.entry?.anims || {};
|
||||
const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null;
|
||||
this.roles = this.baseRoles = clip ? { ...anims, skill: clip } : anims;
|
||||
this.skillIndex = Number.isInteger(index) ? index : null;
|
||||
this._applyRoles();
|
||||
}
|
||||
|
||||
/** The unit's own roles: the manifest's with its equipped skill's clip (setSkillIndex) — what a form ends in. */
|
||||
/**
|
||||
* A fast mover walks on its model's own Run cycle (`anims.run`; PR #275 by @xcdoge): 猎狗pro (moveSpeed 1.9, 行动速度很快)
|
||||
* ships Move_Loop 0.80 s next to Run_Loop 0.53 s. Composes with setSkillIndex; a model without a Run cycle is unchanged.
|
||||
*/
|
||||
setRunMode(on) {
|
||||
this.runMode = !!on;
|
||||
this._applyRoles();
|
||||
}
|
||||
|
||||
/** The unit's own roles (the manifest's, its skill slot's clip and the Run cycle applied) under the form in force. */
|
||||
_applyRoles() {
|
||||
const anims = this.entry?.anims || {};
|
||||
const clip = Number.isInteger(this.skillIndex) && anims.skills ? anims.skills[String(this.skillIndex)] : null;
|
||||
const run = this.runMode && anims.run ? anims.run : null;
|
||||
const base = clip || run ? { ...anims } : anims;
|
||||
if (clip) base.skill = clip;
|
||||
if (run) base.move = run;
|
||||
this.baseRoles = base;
|
||||
this.roles = this.formRoles ? { ...base, ...this.formRoles } : base;
|
||||
}
|
||||
|
||||
/** The unit's own roles: the manifest's with its skill slot's clip (setSkillIndex) and Run cycle — what a form ends in. */
|
||||
_baseRoles() { return this.baseRoles || this.entry?.anims || {}; }
|
||||
|
||||
/**
|
||||
@@ -163,6 +186,7 @@ export class SpineActor {
|
||||
*/
|
||||
setForm(roles, change = null, end = null) {
|
||||
const anims = this._baseRoles();
|
||||
this.formRoles = roles || null; // kept over a later skill slot (_applyRoles)
|
||||
this.roles = roles ? { ...anims, ...roles } : anims;
|
||||
this.endClip = null;
|
||||
if (this.dead) return;
|
||||
@@ -464,7 +488,7 @@ export class SpineActor {
|
||||
if (this.endClip && this.clock >= this.endAt) {
|
||||
const clip = this.endClip;
|
||||
this.endClip = null;
|
||||
if (this.endRoles) this.roles = { ...this._baseRoles(), ...this.endRoles };
|
||||
if (this.endRoles) { this.formRoles = this.endRoles; this.roles = { ...this._baseRoles(), ...this.endRoles }; }
|
||||
if (!this.dead) this._change(clip);
|
||||
}
|
||||
if (this.windUntil != null && this.clock >= this.windUntil) {
|
||||
|
||||
+58
-10
@@ -137,8 +137,9 @@ export const SPINE_STUCK_MS = 5000;
|
||||
/** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */
|
||||
const RAISED_Z = 0.12;
|
||||
/**
|
||||
* Flying units hover this many tiles above the ground they cross (PR #211 by @xcdoge; the owner's decision of
|
||||
* 2026-10-06; docs/research/12-flying-visuals-official.md).
|
||||
* Flying units hover this many tiles up (PR #211 by @xcdoge; the owner's decision of 2026-10-06;
|
||||
* docs/research/12-flying-visuals-official.md): an enemy flyer above the road (z 0) whatever tile it crosses — a raised
|
||||
* block under it is no step (GitHub #277) —, an operator or summon above its tile.
|
||||
*
|
||||
* The official client's fly offset is a **single constant, `Vector3(0, 0.35f, 0)`**: `Torappu.Battle.CharacterAnimator`'s
|
||||
* constructor stores it in the instance field at +0x114 (`GameAssembly.dll` 0x180600555 reads the constant at 0x186a78a50 =
|
||||
@@ -288,6 +289,18 @@ export const FORMS = Object.freeze({
|
||||
enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
|
||||
enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
|
||||
enemy_1539_reid: rebirth('Revive_Begin', 'Revive_Loop', 'Revive_End'),
|
||||
// 重生 on the skeleton's own Revive clip (PR #275 by @xcdoge; content/enemies/leaders.js kitUglyThing / kitXi): 巨大的丑东西
|
||||
// — Revive 8.67 s of the 10 s 重生 (its self-destruct 2.17 s in), then the fleeing 大祭司 (Idle_2 / Move_2 / Stun_2;
|
||||
// 不进行攻击) holding its idle while the sim still holds the 重生 (reported as a stun); 自在 — Revive_01 5.33 s for the
|
||||
// 5 s 重生, then the same model, stronger (reborn.atk)
|
||||
enemy_1512_mcmstr: Object.freeze({
|
||||
reborn: Object.freeze({ change: 'Revive', next: 'form2', roles: Object.freeze({ idle: 'Idle_2', deploy: 'Idle_2', move: loop('Idle_2'), stun: loop('Idle_2'), attack: null, skill: null }) }),
|
||||
form2: Object.freeze({ change: null, roles: Object.freeze({ ...clipSet('Idle_2', 'Move_2', 'Die'), stun: loop('Stun_2') }) }),
|
||||
}),
|
||||
enemy_1517_xi: Object.freeze({
|
||||
reborn: Object.freeze({ change: 'Revive_01', next: 'form2', roles: Object.freeze({}) }),
|
||||
form2: Object.freeze({ change: null, roles: Object.freeze({}) }),
|
||||
}),
|
||||
enemy_1516_jakill: Object.freeze({
|
||||
reborn: Object.freeze({ change: 'C1_Die', roles: JAKILL2 }),
|
||||
form2: Object.freeze({ change: null, roles: JAKILL2 }),
|
||||
@@ -354,6 +367,9 @@ export class UnitView {
|
||||
this.info = { ...info };
|
||||
this.id = info.id;
|
||||
this.uid = info.uid ?? null;
|
||||
// the skill slot whose Spine clip the unit shows (DESIGN §16): an ally's equipped skill; an enemy's cast slot when the
|
||||
// sim reports one (`cast` event, setSkillSlot — a multi-skill boss's Skill_01..04, PR #275)
|
||||
this.skillIndex = Number.isInteger(info.skillIndex) ? info.skillIndex : null;
|
||||
this.prep = !!opts.prep;
|
||||
this.lodIdle = opts.lod === 'idle';
|
||||
this.culled = false; // outside the viewport this frame (not animated, not drawn)
|
||||
@@ -364,6 +380,9 @@ export class UnitView {
|
||||
// enemies: the official prefab's size factor (1 for operators, summons and enemies at the standard size)
|
||||
const def = this.isEnemy && ctx.lookupDef ? ctx.lookupDef(info) : null;
|
||||
this.modelK = this.isEnemy ? enemyModelScale(def) : 1;
|
||||
// a fast enemy walks on its model's own Run cycle when it has one (PR #275 by @xcdoge: 猎狗pro, moveSpeed 1.9, Run_Loop
|
||||
// 0.53 s next to Move_Loop 0.80 s; also 深池侦察犬 1.7). [ASSUMED] the threshold: faster than the standard 1
|
||||
this.moveFast = this.isEnemy && Number(def?.stats?.moveSpeed) > 1;
|
||||
// the official's own model quirks (enemies.json, read from its battle prefabs — tools/local-extract/enemy_model_offsets.py,
|
||||
// PR #211): a vertical stretch (its Graphic scale's sy / sx, the two 帝国炮火先兆者 at 1.263) and a mirrored X scale
|
||||
// (the Graphic's sx is negative, so the official draws the authored model flipped: 木制瑞印)
|
||||
@@ -373,9 +392,13 @@ export class UnitView {
|
||||
this.golden = !!info.golden;
|
||||
this.tier = clamp(Number(info.tier) || 1, 1, 6);
|
||||
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
|
||||
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit)
|
||||
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies)
|
||||
/** @type {number|null} */
|
||||
this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z
|
||||
/** @type {number|null} */
|
||||
this.shadowZTarget = null;
|
||||
this.flying = info.motion === 'FLY';
|
||||
this.hover = 0; // flying: body height above the ground under it
|
||||
this.hover = 0; // flying: body height above the road / the ground under it
|
||||
this.dir = this.isEnemy ? null : unitDir(info);
|
||||
// whether the direction is known (UnitInfo / piece `dir`), not just the legacy ±1: battle and scouting views show
|
||||
// the ground wedge only then (a derived RIGHT would mislabel an UP / DOWN operator)
|
||||
@@ -541,7 +564,8 @@ export class UnitView {
|
||||
let actor = null;
|
||||
try {
|
||||
actor = new SpineActor(data, entry);
|
||||
actor.setSkillIndex(this.info.skillIndex);
|
||||
actor.setSkillIndex(this.skillIndex ?? undefined);
|
||||
actor.setRunMode(this.moveFast);
|
||||
// enemies play their attack clip once per attack, then walk on (GitHub #58: the sim stands them for that clip)
|
||||
actor.clipPerAttack = this.isEnemy;
|
||||
} catch (err) {
|
||||
@@ -704,10 +728,17 @@ export class UnitView {
|
||||
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
|
||||
this.x = s.x; this.y = s.y;
|
||||
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
|
||||
// ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up)
|
||||
const gz = this.isEnemy && !this.flying ? 0 : groundZ(this.ctx, s.x, s.y);
|
||||
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
|
||||
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
|
||||
// constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one
|
||||
// high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it.
|
||||
const floor = groundZ(this.ctx, s.x, s.y);
|
||||
const gz = this.isEnemy ? 0 : floor;
|
||||
if (this.zTarget == null) this.z = gz;
|
||||
this.zTarget = gz;
|
||||
this.shadowZTarget = this.isEnemy && this.flying ? floor : null;
|
||||
if (this.shadowZTarget == null) this.shadowZ = null;
|
||||
else if (this.shadowZ == null) this.shadowZ = this.z;
|
||||
if (s.maxHp > 0) this.maxHp = s.maxHp;
|
||||
const hp = clamp(s.hp, 0, this.maxHp);
|
||||
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
|
||||
@@ -837,6 +868,17 @@ export class UnitView {
|
||||
if (this.actor) this.actor.setSkill(on);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill slot an enemy casts (the sim's `cast` event; PR #275 by @xcdoge): its clip set swaps to that slot's skill
|
||||
* clip (`anims.skills`, a multi-skill boss's Skill_01..04), which the SKILL flag right after plays. The same slot again
|
||||
* or a non-slot changes nothing.
|
||||
*/
|
||||
setSkillSlot(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index === this.skillIndex) return;
|
||||
this.skillIndex = index;
|
||||
if (this.actor) this.actor.setSkillIndex(index);
|
||||
}
|
||||
|
||||
onDeploy() {
|
||||
this.fadeIn = 0;
|
||||
if (!this.alive) this.revive();
|
||||
@@ -941,6 +983,10 @@ export class UnitView {
|
||||
const d = this.zTarget - this.z;
|
||||
this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12);
|
||||
}
|
||||
if (this.shadowZTarget != null && this.shadowZ !== this.shadowZTarget) {
|
||||
const d = this.shadowZTarget - this.shadowZ;
|
||||
this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12);
|
||||
}
|
||||
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
|
||||
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
|
||||
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
|
||||
@@ -977,9 +1023,11 @@ export class UnitView {
|
||||
if (this._cull(bx, by, s, dt)) return;
|
||||
const flip = (this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing) * (this.mirrorX ? -1 : 1);
|
||||
|
||||
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
const sh = cam.project(this.x, this.y, this.z, SH_P);
|
||||
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything); an enemy
|
||||
// flyer's lies on the tile under it while its body hovers from the road (shadowZ, GitHub #277)
|
||||
const shz = this.shadowZ ?? this.z;
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, shz);
|
||||
const sh = cam.project(this.x, this.y, shz, SH_P);
|
||||
this.shadow.position.set(sh.x, sh.y);
|
||||
const shw = s * (this.isBoss ? 1.6 : 0.95) / this.shadow.texture.width;
|
||||
this.shadow.scale.set(shw, shw * (this.shadow.texture === shadowTexture() ? 1 : 1.05));
|
||||
|
||||
@@ -77,6 +77,92 @@ describe('animation-role resolver (research 07 §5.4)', () => {
|
||||
assert.deepEqual(r.skill, { begin: 'Skill2_Begin', loop: 'Skill2_Loop', end: 'Skill2_End', index: 1, idle: null });
|
||||
});
|
||||
|
||||
// PR #275 (@xcdoge): an enemy's manifest only ever had skill index 0 (plan.mjs passes [0]), so a multi-skill boss could
|
||||
// show one cast clip — 盐风主教昆图斯 has Skill_01..04. With `numberedSkills` (spine.mjs: enemy models) every numbered
|
||||
// skill clip is resolved after the caller's indices, whose first stays the primary.
|
||||
test('numbered skill clips of an enemy are all resolved (a multi-skill boss), the primary stays the caller’s first', () => {
|
||||
const names = ['Attack', 'Default', 'Die', 'Idle', 'Skill_01', 'Skill_02', 'Skill_03', 'Skill_04'];
|
||||
assert.equal(resolveRoles(names, { skillIndices: [0] }).skills, undefined, 'without the option: index 0 only (operators)');
|
||||
const r = resolveRoles(names, { skillIndices: [0], numberedSkills: true });
|
||||
assert.deepEqual(Object.keys(r.skills).sort(), ['0', '1', '2', '3']);
|
||||
assert.equal(r.skills['1'].loop, 'Skill_02');
|
||||
assert.equal(r.skills['3'].loop, 'Skill_04');
|
||||
assert.equal(r.skill.loop, 'Skill_01', 'the first index stays the primary clip');
|
||||
const first = resolveRoles(names, { skillIndices: [2], numberedSkills: true });
|
||||
assert.equal(first.skill.index, 2, 'a caller-declared primary still wins');
|
||||
assert.equal(first.skill.loop, 'Skill_03');
|
||||
// 自在: Skill_01 / Skill_01_02 are one slot, Skill_02_Begin / Loop / End the next
|
||||
const xi = resolveRoles(['Attack_01', 'Die', 'Idle', 'Skill_01', 'Skill_01_02', 'Skill_02_Begin', 'Skill_02_End', 'Skill_02_Loop'], { numberedSkills: true });
|
||||
assert.deepEqual(xi.skills['1'], { begin: 'Skill_02_Begin', loop: 'Skill_02_Loop', end: 'Skill_02_End', index: 1, idle: null });
|
||||
// phase-style skill clips (no index in the name) stay a single primary skill
|
||||
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Skill_Begin', 'Skill_Loop', 'Skill_End'], { numberedSkills: true }).skills, undefined);
|
||||
});
|
||||
|
||||
// PR #275: the stun family is spelled Stun / Stun_End, Stun_1 / Stun_2 (巨大的丑东西: first form, second form) and
|
||||
// Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤); without a role a stunned enemy freezes its clip.
|
||||
test('stun clips: numbered Stun_N and the Dizzy_* family are recognised', () => {
|
||||
const mc = resolveRoles(['Attack_1', 'Die', 'Idle_1', 'Idle_2', 'Move_1', 'Move_2', 'Stun_1', 'Stun_2'], {});
|
||||
assert.deepEqual(mc.stun, { begin: null, loop: 'Stun_1', end: null });
|
||||
const dz = resolveRoles(['Attack', 'Die', 'Idle_B', 'Move', 'Dizzy_Begin', 'Dizzy_Loop', 'Dizzy_End', 'Dizzy_Die'], {});
|
||||
assert.deepEqual(dz.stun, { begin: 'Dizzy_Begin', loop: 'Dizzy_Loop', end: 'Dizzy_End' });
|
||||
assert.deepEqual(resolveRoles(['Attack', 'Die', 'Idle', 'Stun', 'Stun_End'], {}).stun, { begin: null, loop: 'Stun', end: 'Stun_End' });
|
||||
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).stun, null);
|
||||
});
|
||||
|
||||
// PR #275: a model's own Run cycle is a role of its own (猎狗pro: Move_Loop 0.80 s, Run_Loop 0.53 s); the move choice
|
||||
// is unchanged, the renderer switches to it for a fast enemy.
|
||||
test('the model’s own Run cycle becomes a separate run role', () => {
|
||||
const names = ['Attack', 'Default', 'Die', 'Idle', 'Move_Begin', 'Move_End', 'Move_Loop', 'Run_Begin', 'Run_End', 'Run_Loop'];
|
||||
const r = resolveRoles(names, {});
|
||||
assert.deepEqual(r.move, { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' }, 'the move role is unchanged');
|
||||
assert.deepEqual(r.run, { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' });
|
||||
assert.ok(roleAnimationNames(r).includes('Run_Loop'), 'the manifest writer validates the Run clips');
|
||||
assert.equal(resolveRoles(['Attack', 'Die', 'Idle', 'Move'], {}).run, undefined, 'no Run cycle, no role');
|
||||
});
|
||||
|
||||
// The manifest (tools/fetch-assets.mjs --offline with this resolver): every model whose skeleton has a stun / Run clip
|
||||
// has the role, and every enemy with numbered skill clips has one clip per slot.
|
||||
describe('data/assets.json roles (PR #275)', () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
|
||||
const models = [];
|
||||
for (const [id, e] of Object.entries(manifest.enemies || {})) if (e.spine) models.push([`enemies.${id}`, e.spine]);
|
||||
for (const [id, e] of Object.entries(manifest.tokens || {})) if (e.spine) models.push([`tokens.${id}`, e.spine]);
|
||||
for (const [id, e] of Object.entries(manifest.chars || {})) if (e.spine?.front) models.push([`chars.${id}`, e.spine.front]);
|
||||
const walk = (re, ok) => {
|
||||
const bad = [];
|
||||
let seen = 0;
|
||||
for (const [id, sp] of models) {
|
||||
if (!sp.animations || !Object.keys(sp.animations).some((n) => re.test(n))) continue;
|
||||
seen++;
|
||||
if (!ok(sp.anims || {}, id)) bad.push(id);
|
||||
}
|
||||
return { bad, seen };
|
||||
};
|
||||
test('every model with a stun clip has a stun role', () => {
|
||||
const { bad, seen } = walk(/^(stun|dizzy)/i, (a) => !!a.stun);
|
||||
assert.ok(seen >= 6, `models with a stun clip (${seen})`);
|
||||
assert.deepEqual(bad, []);
|
||||
});
|
||||
test('every model with a Run clip has a run role', () => {
|
||||
const { bad, seen } = walk(/^run/i, (a) => !!a.run);
|
||||
assert.ok(seen >= 3, `models with a Run clip (${seen})`);
|
||||
assert.deepEqual(bad, []);
|
||||
});
|
||||
test('every enemy with numbered skill clips has a clip per slot (盐风主教昆图斯: Skill_01..04)', () => {
|
||||
const { bad, seen } = walk(/^Skill_?0*[1-9](?:$|_)/i, (a, id) => !id.startsWith('enemies.') || !!a.skills);
|
||||
assert.ok(seen >= 10, `models with numbered skill clips (${seen})`);
|
||||
assert.deepEqual(bad, []);
|
||||
const q = manifest.enemies.enemy_1521_dslily.spine.anims;
|
||||
assert.deepEqual(Object.values(q.skills).map((c) => c.loop), ['Skill_01', 'Skill_02', 'Skill_03', 'Skill_04']);
|
||||
assert.equal(q.skill.loop, 'Skill_01', 'the primary clip is unchanged');
|
||||
// operators keep their pool's indices (宴's fix, PREFAB_SPINE_ROLES / DESIGN §25.22.9, stays as it was)
|
||||
const utage = manifest.chars.char_337_utage.spine.front.anims;
|
||||
assert.deepEqual(Object.keys(utage.skills).sort(), ['0', '1']);
|
||||
assert.equal(utage.skills['0'].loop, 'Skill_Loop');
|
||||
assert.equal(utage.skill.loop, 'Attack');
|
||||
});
|
||||
});
|
||||
|
||||
test('pure supporter without Attack (char_4134_cetsyr)', () => {
|
||||
const r = resolveRoles(['Die', 'Idle', 'Skill_1_Begin', 'Skill_1_End', 'Skill_1_Loop', 'Skill_2_Begin', 'Skill_2_Loop', 'Start', 'Stun', 'Stun_Begin'], { skillIndices: [1] });
|
||||
assert.deepEqual(r.attack, { begin: null, loop: 'Skill_1_Loop', end: null, via: 'skill' });
|
||||
|
||||
@@ -378,3 +378,49 @@ test('render/app.js hands the `form` of a sim fx (shared/protocol.js fxForm) wit
|
||||
assert.match(src, /if \(!\(late > 0\)\) fx\.simFx\(/);
|
||||
assert.match(src, /interp\.takeEvents\(renderT, EVS, renderT - 1\.5, LATE\);/);
|
||||
});
|
||||
|
||||
// PR #275 (@xcdoge): 巨大的丑东西 (kitUglyThing: KO ⇒ 10 s 重生 ⇒ the fleeing 大祭司) and 自在 (kitXi: KO ⇒ 5 s 重生 ⇒ the
|
||||
// same model, stronger) had no clip set, so their 重生 played no clip. The Revive clip is the 重生's change clip; the sim
|
||||
// reports the 重生 as a stun (archetypes.js reborn), so 巨大的丑东西's 重生 holds the 大祭司's idle after its 8.67 s clip.
|
||||
test('“巨大的丑东西” / “自在”: the 重生 plays the skeleton’s Revive clip, then the second form', () => {
|
||||
const mc = FORMS.enemy_1512_mcmstr;
|
||||
assert.equal(mc.reborn.change, 'Revive', 'the 8.67 s Revive clip opens the 10 s 重生');
|
||||
assert.equal(mc.reborn.next, 'form2');
|
||||
assert.equal(mc.reborn.roles.stun.loop, 'Idle_2', 'the rest of the 重生 (a sim stun) holds the 大祭司 idle, not the first form’s Stun_1');
|
||||
assert.equal(mc.form2.roles.idle, 'Idle_2');
|
||||
assert.equal(mc.form2.roles.move.loop, 'Move_2');
|
||||
assert.equal(mc.form2.roles.stun.loop, 'Stun_2', 'the 大祭司 stunned: its own clip');
|
||||
assert.equal(mc.form2.roles.attack, null, 'the 大祭司 never attacks');
|
||||
const xi = FORMS.enemy_1517_xi;
|
||||
assert.equal(xi.reborn.change, 'Revive_01', 'the 5.33 s Revive_01 is the 5 s 重生');
|
||||
assert.deepEqual(xi.form2.roles, {}, 'the same model, only stronger (reborn.atk)');
|
||||
});
|
||||
|
||||
// Every clip a form set names must exist in that model's manifest entry: a typo is a silently missing animation.
|
||||
test('every clip FORMS names exists in its model’s manifest', () => {
|
||||
const clipsOf = (v, out = new Set()) => {
|
||||
if (typeof v === 'string') { if (v) out.add(v); return out; }
|
||||
if (Array.isArray(v)) { for (const x of v) clipsOf(x, out); return out; }
|
||||
if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) if (k !== 'via' && k !== 'next') clipsOf(x, out);
|
||||
return out;
|
||||
};
|
||||
const entries = (id) => {
|
||||
const out = [];
|
||||
const e = assets.enemies?.[id]?.spine; if (e) out.push(e.front || e);
|
||||
const t = assets.tokens?.[id]?.spine; if (t) out.push(t);
|
||||
const c = assets.chars?.[id]?.spine; if (c) out.push(...[c.front, c.back].filter(Boolean));
|
||||
return out;
|
||||
};
|
||||
const bad = [];
|
||||
let checked = 0;
|
||||
for (const [id, forms] of Object.entries(FORMS)) {
|
||||
const es = entries(id);
|
||||
if (!es.length) continue;
|
||||
checked++;
|
||||
for (const [form, spec] of Object.entries(forms)) {
|
||||
for (const clip of clipsOf(spec)) if (!es.some((e) => e.animations && Object.hasOwn(e.animations, clip))) bad.push(`${id}.${form}: ${clip}`);
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 10, `the table was walked (${checked} models)`);
|
||||
assert.deepEqual(bad, [], 'clips named by a form set but missing from the manifest');
|
||||
});
|
||||
@@ -77,6 +77,30 @@ describe('Spine actor + audio use the equipped skill', () => {
|
||||
assert.equal((await actorFor(1)).roles.skill.loop, 'Skill_3_Loop', 'no clip for S2 in the model: primary');
|
||||
});
|
||||
|
||||
// PR #275 (@xcdoge): the slot an enemy ability casts (render/app.js `case 'cast'` → UnitView.setSkillSlot) swaps its
|
||||
// skill clip mid-battle, so a multi-skill boss does not show one cast animation for every ability.
|
||||
test('an enemy cast slot swaps its skill clip mid-battle (a form in force is kept)', async () => {
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam });
|
||||
const v = new UnitView(ctx, { id: 9, side: 'enemy', kind: 'enemy', defId: 'enemy_1521_dslily', x: 5, y: 10, maxHp: 100 }, {});
|
||||
await tick(); await tick();
|
||||
assert.ok(v.actor, 'spine actor built');
|
||||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'the manifest primary clip to start with');
|
||||
assert.equal(v.skillIndex, null, 'no slot reported yet');
|
||||
v.setSkillSlot(0);
|
||||
assert.equal(v.skillIndex, 0);
|
||||
assert.equal(v.actor.roles.skill.loop, 'Skill_1', 'that slot’s own clip');
|
||||
v.setSkillSlot(-1);
|
||||
v.setSkillSlot(1.5);
|
||||
assert.equal(v.skillIndex, 0, 'a non-slot is ignored');
|
||||
v.actor.setForm({ idle: 'Attack' });
|
||||
v.setSkillSlot(2);
|
||||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'the next slot');
|
||||
assert.equal(v.actor.roles.idle, 'Attack', 'the form in force survives the slot change');
|
||||
v.actor.setForm(null);
|
||||
assert.equal(v.actor.roles.idle, 'Idle');
|
||||
assert.equal(v.actor.roles.skill.loop, 'Skill_3_Loop', 'and the slot survives the form ending');
|
||||
});
|
||||
|
||||
test('audio: the equipped skill’s own ON_SKILL_START sound', () => {
|
||||
const manifest = { audio: { sfx: { units: { char_350_surtr: { skill: '/sfx/s3.mp3', skills: { 0: '/sfx/s1.mp3', 2: '/sfx/s3.mp3' } } } } } };
|
||||
const a = new AudioManager({ win: null, getManifest: () => manifest });
|
||||
|
||||
@@ -345,3 +345,93 @@ describe('flying units hover FLY_HOVER above the ground, whatever their model',
|
||||
assert.ok(Math.abs(fly.shadow.position.y - ground.shadow.position.y) < 1e-9, 'the shadow stays on the ground');
|
||||
});
|
||||
});
|
||||
|
||||
// GitHub #277 (@FrogThai): 飞机经过一格方块时会跟走楼梯一样,有高低差 — a flyer crossing one raised tile (high ground,
|
||||
// a forbidden block) rose onto the block and dropped back like a step, because the view added the tile's height under
|
||||
// it before its FLY_HOVER. The official lift is one constant over the route (docs/research/12: Vector3(0, 0.35, 0) added
|
||||
// while flying), so an enemy flyer hovers from the road (z 0) whatever tile it crosses; its shadow lies on the tile top
|
||||
// under it. Ground enemies keep to the road as before, and an operator on high ground keeps standing on the block.
|
||||
describe('an enemy flyer crossing a raised tile keeps its height (GitHub #277)', () => {
|
||||
const RAISED = { row: 12, col: 6, h: 0.42 }; // one high-ground block ('h', TILE_H.wall) on the flyer's row
|
||||
const heightAt = (r, c) => (r === RAISED.row && c === RAISED.col ? RAISED.h : 0);
|
||||
const sample = (x, flags) => ({ x, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags, anim: 1, vx: 0.5 });
|
||||
|
||||
test('the body stays FLY_HOVER above the road over the block; the shadow lies on the block top', async () => {
|
||||
const { FLY_HOVER } = await import('../../public/js/render/units.js');
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(), cam, heightAt });
|
||||
const fly = new UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', defId: 'enemy_1005_yokai', x: 4, y: 12, maxHp: 100, motion: 'FLY' }, {});
|
||||
await tick(); await tick();
|
||||
let t = 0;
|
||||
const step = (x) => { fly.sync(sample(x, 512), t); fly.update(1 / 60, cam(), t); t += 1 / 60; };
|
||||
for (let i = 0; i < 180; i++) step(4); // settle the lift on the road
|
||||
const heights = [];
|
||||
for (let i = 0; i <= 80; i++) { step(4 + i * 0.05); heights.push(fly.z + fly.hover); } // x 4 → 8 across col 6
|
||||
const rise = Math.max(...heights) - Math.min(...heights);
|
||||
assert.ok(rise < 1e-6, `the body height never changes over the block (rose ${rise.toFixed(3)} tiles; before: +${RAISED.h})`);
|
||||
assert.ok(Math.abs(heights[0] - FLY_HOVER) < 1e-3, `FLY_HOVER above the road (${heights[0].toFixed(3)})`);
|
||||
for (let i = 0; i < 60; i++) step(6); // hold over the block
|
||||
assert.equal(fly.z, 0, 'the flyer hovers from the road plane');
|
||||
const c = cam();
|
||||
assert.ok(Math.abs(fly.screen.y - c.project(6, 12, FLY_HOVER).y) < 1e-6, 'drawn FLY_HOVER above the road, not above the block');
|
||||
assert.ok(Math.abs(fly.shadow.position.y - c.project(6, 12, RAISED.h).y) < 0.05, 'its shadow lies on the block top under it');
|
||||
for (let i = 0; i < 60; i++) step(8); // back over the road
|
||||
assert.ok(Math.abs(fly.shadow.position.y - c.project(8, 12, 0).y) < 0.05, 'and on the road again past it');
|
||||
});
|
||||
|
||||
test('ground enemies stay on the road and an operator on the block stands on its top', async () => {
|
||||
const ctx = fakeViewCtx(fake.P, { assets: store(), cam, heightAt });
|
||||
const walker = new UnitView(ctx, { id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 6, y: 12, maxHp: 100 }, {});
|
||||
const op = new UnitView(ctx, { id: 3, side: 'ally', kind: 'chess', defId: 'char_x', tier: 1, x: 6, y: 12, maxHp: 100 }, {});
|
||||
await tick(); await tick();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
walker.sync(sample(6, 0), i / 60); walker.update(1 / 60, cam(), i / 60);
|
||||
op.sync({ ...sample(6, 0), anim: 0, vx: 0 }, i / 60); op.update(1 / 60, cam(), i / 60);
|
||||
}
|
||||
assert.equal(walker.z, 0, 'a ground enemy is never popped onto a block');
|
||||
assert.ok(Math.abs(op.z - RAISED.h) < 1e-6, `the operator stands on the high ground (${op.z})`);
|
||||
});
|
||||
});
|
||||
|
||||
// PR #275 (@xcdoge): 猎狗pro ships Move_Loop 0.80 s next to Run_Loop 0.53 s and its moveSpeed is 1.9, so a fast enemy
|
||||
// walks on its model's own Run cycle (anims.run) while a standard one keeps Move; the cast slot composes with it.
|
||||
describe('a fast enemy walks on its Run cycle (PR #275)', () => {
|
||||
const entry = {
|
||||
skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'],
|
||||
anims: {
|
||||
idle: 'Idle', deploy: 'Idle', die: 'Die', attack: null,
|
||||
move: { begin: 'Move_Begin', loop: 'Move_Loop', end: 'Move_End' },
|
||||
run: { begin: 'Run_Begin', loop: 'Run_Loop', end: 'Run_End' },
|
||||
skill: { begin: null, loop: 'Skill_01', end: null, index: 0, idle: null },
|
||||
skills: { 0: { begin: null, loop: 'Skill_01', end: null, index: 0, idle: null }, 1: { begin: null, loop: 'Skill_02', end: null, index: 1, idle: null } },
|
||||
},
|
||||
animations: { Idle: 1, Die: 0.67, Move_Begin: 0.17, Move_Loop: 0.8, Move_End: 0.17, Run_Begin: 0.17, Run_Loop: 0.53, Run_End: 0.17, Skill_01: 1, Skill_02: 1 },
|
||||
};
|
||||
const assets = {
|
||||
picture: () => null, image: async () => null, spineEntry: () => entry,
|
||||
spine: { acquire: async () => ({ animations: Object.keys(entry.animations).map((name) => ({ name })) }), release() {} },
|
||||
};
|
||||
const enemyView = async (id, speed, side = 'enemy') => {
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam, lookupDef: () => ({ stats: { moveSpeed: speed } }) });
|
||||
const v = new UnitView(ctx, { id, side, kind: side === 'enemy' ? 'enemy' : 'op', defId: 'enemy_1000_gopro_2', x: 5, y: 12, maxHp: 100 }, {});
|
||||
await tick(); await tick();
|
||||
assert.ok(v.actor, 'spine actor built');
|
||||
return v;
|
||||
};
|
||||
|
||||
test('moveSpeed 1.9 moves on Run, 1 on Move; the cast slot and the Run cycle compose', async () => {
|
||||
const hound = await enemyView(1, 1.9);
|
||||
assert.equal(hound.actor.roles.move.loop, 'Run_Loop');
|
||||
const slug = await enemyView(2, 1);
|
||||
assert.equal(slug.actor.roles.move.loop, 'Move_Loop');
|
||||
// the MOVE anim code plays it
|
||||
hound.sync({ x: 5, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags: 0, anim: 1, vx: 0.5 }, 1);
|
||||
for (let i = 0; i < 20; i++) hound.update(1 / 60, cam(), i / 60);
|
||||
assert.match(String(hound.actor.current), /^Run/, `playing ${hound.actor.current}`);
|
||||
hound.setSkillSlot(1);
|
||||
assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot');
|
||||
assert.equal(hound.actor.roles.move.loop, 'Run_Loop', 'and the Run cycle survives it');
|
||||
hound.actor.setRunMode(false);
|
||||
assert.equal(hound.actor.roles.move.loop, 'Move_Loop');
|
||||
assert.equal(hound.actor.roles.skill.loop, 'Skill_02', 'the cast slot survives that too');
|
||||
});
|
||||
});
|
||||
@@ -28,17 +28,21 @@
|
||||
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
|
||||
// idle = P_Idle (replaces idle while the skill is active);
|
||||
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
|
||||
// else attack (via attack)
|
||||
// else attack (via attack); with `numberedSkills` (enemies) every numbered skill clip of the skeleton
|
||||
// (Skill_01, Skill_2_Loop …) adds its index after the caller's, giving `skills` (a multi-skill boss)
|
||||
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
|
||||
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
|
||||
// → Run → first /^Move/ → null
|
||||
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
|
||||
// run : Run_Begin/Loop/End → Run → <form>Run → absent (only a model with its own Run cycle has the role)
|
||||
// stun : (Stun_Begin | Dizzy_Begin) + (Stun → Stun_1 → Dizzy_Loop) (+ Stun_End | Dizzy_End) → Stun_Begin alone
|
||||
// → null (renderer: freeze the track)
|
||||
|
||||
/**
|
||||
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
|
||||
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
|
||||
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
|
||||
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
|
||||
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, run?: Clip,
|
||||
* stun: Clip|null }} Roles
|
||||
*/
|
||||
|
||||
function makeFinder(names) {
|
||||
@@ -221,10 +225,27 @@ function resolveMove(names, find, form) {
|
||||
return any ? clip(null, any, null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's own run cycle, when it has one (猎狗pro: Move_Loop 0.80 s next to Run_Loop 0.53 s): a role of its own, not
|
||||
* the move choice — the renderer walks a fast enemy on it (render/units.js `moveFast`; PR #275 by @xcdoge).
|
||||
*/
|
||||
function resolveRun(find, form) {
|
||||
const t = triple(find, 'Run');
|
||||
if (t) return t;
|
||||
const r = find('Run') ?? form('Run');
|
||||
return r ? clip(null, r, null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stun family is spelled two ways and sometimes numbered (PR #275 by @xcdoge): Stun / Stun_Begin / Stun_End
|
||||
* (吉兆飞鳞, 乌顶巨角卢鲁), Stun_1 / Stun_2 (巨大的丑东西: its first and its second form — render/units.js FORMS gives the
|
||||
* second form Stun_2), Dizzy_Begin / Dizzy_Loop / Dizzy_End (斩胄之剑 / 破胄之锤, stun-immune in this mode). Without a role
|
||||
* a stunned enemy freezes its current clip.
|
||||
*/
|
||||
function resolveStun(find) {
|
||||
const stun = find('Stun');
|
||||
const begin = find('Stun_Begin');
|
||||
if (stun) return clip(begin, stun, find('Stun_End'));
|
||||
const stun = find('Stun') ?? find('Stun_1') ?? find('Dizzy_Loop');
|
||||
const begin = find('Stun_Begin') ?? find('Dizzy_Begin');
|
||||
if (stun) return clip(begin, stun, find('Stun_End') ?? find('Dizzy_End'));
|
||||
if (begin) return clip(null, begin, null);
|
||||
return null;
|
||||
}
|
||||
@@ -241,6 +262,8 @@ function resolveDie(names, find, form) {
|
||||
* @param {string[]} animationNames all animation names in the skeleton
|
||||
* @param {object} [opts]
|
||||
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
|
||||
* @param {boolean} [opts.numberedSkills] also resolve every numbered skill clip of the skeleton (Skill_01, Skill_2_Loop …)
|
||||
* after the caller's indices: an enemy's per-skill clips (a multi-skill boss such as 盐风主教昆图斯, Skill_01..04)
|
||||
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
|
||||
* @returns {Roles}
|
||||
*/
|
||||
@@ -256,6 +279,16 @@ export function resolveRoles(animationNames, opts = {}) {
|
||||
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
|
||||
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
|
||||
if (!indices.length) indices.push(0);
|
||||
// an enemy's numbered skill clips (PR #275 by @xcdoge: its manifest only ever had index 0, so a multi-skill boss could
|
||||
// show one cast clip): appended after the caller's indices, whose first stays the primary `skill`
|
||||
if (opts.numberedSkills) {
|
||||
const found = new Set();
|
||||
for (const n of names) {
|
||||
const m = /^Skill_?0*(\d+)(?:$|_)/i.exec(n);
|
||||
if (m && Number(m[1]) >= 1 && Number(m[1]) <= 10) found.add(Number(m[1]) - 1);
|
||||
}
|
||||
for (const i of [...found].sort((a, b) => a - b)) if (!indices.includes(i)) indices.push(i);
|
||||
}
|
||||
/** @type {Record<string, SkillClip>} */
|
||||
const skills = {};
|
||||
for (const i of indices) {
|
||||
@@ -272,6 +305,8 @@ export function resolveRoles(animationNames, opts = {}) {
|
||||
move: resolveMove(names, find, form),
|
||||
stun: resolveStun(find),
|
||||
};
|
||||
const run = resolveRun(find, form);
|
||||
if (run) roles.run = run;
|
||||
if (indices.length > 1) roles.skills = skills;
|
||||
return roles;
|
||||
}
|
||||
@@ -289,7 +324,7 @@ export function roleAnimationNames(roles) {
|
||||
};
|
||||
if (!roles) return [];
|
||||
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
|
||||
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
|
||||
for (const k of ['attack', 'attackDown', 'skill', 'move', 'run', 'stun']) addClip(roles[k]);
|
||||
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
|
||||
return [...out];
|
||||
}
|
||||
@@ -166,7 +166,8 @@ export function applyRoleFix(roles, fix, durations) {
|
||||
/**
|
||||
* Spine metadata of extracted models (findLocalEnemyModels / findLocalTokenModels), parsed like processModels does — but
|
||||
* read only: the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
|
||||
* Roles are resolved for skill index 0, like the web enemy and token models, then LOCAL_SPINE_ROLES applied.
|
||||
* Roles are resolved for skill index 0 (an enemy also for its numbered skill clips), like the web enemy and token models,
|
||||
* then LOCAL_SPINE_ROLES applied.
|
||||
* @param {string} root absolute public/assets directory
|
||||
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
|
||||
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
|
||||
@@ -192,7 +193,7 @@ export async function localSpineMeta(root, found) {
|
||||
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
|
||||
if (!sk.animations.length) throw new Error('skeleton has no animations');
|
||||
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
|
||||
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
|
||||
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], numberedSkills: id.startsWith('enemy_'), durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
|
||||
if (fixed.missing.length) problems.push(`${id}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
|
||||
meta[id] = {
|
||||
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
|
||||
@@ -330,7 +331,9 @@ export async function processModels(models, { root, dl, cachePath, download = tr
|
||||
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
|
||||
const key = String(m.key);
|
||||
const id = key.startsWith('enemy:') ? key.slice('enemy:'.length) : key.startsWith('op:') ? key.split(':')[1] : null; // op:<char>:front|back
|
||||
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
|
||||
// an enemy's numbered skill clips are resolved too (anims.skills: the clip of each skill slot it casts, PR #275);
|
||||
// operators keep the pool's indices (their equipped skill picks among them)
|
||||
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: m.skillIndices, numberedSkills: key.startsWith('enemy:'), durations: sk.durations }), id ? PREFAB_SPINE_ROLES[id] : undefined, sk.durations);
|
||||
if (fixed.missing.length) problems.push(`${m.key}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
|
||||
const anims = fixed.roles;
|
||||
entries.set(m.key, {
|
||||
|
||||
Reference in new issue
Block a user