feedback5 WY: flying units hover 1.3 tiles (the client's 0.35 ÷ the standard prefab scale 0.27); the 帝国炮火先兆者 pair is drawn 1.263× taller and 木制瑞印 mirrored, as their official prefabs (from PR #211 by @xcdoge; the owner's decision of 2026-10-06)
Ported by hand from PR #211 by @xcdoge: - render/units.js: FLY_HOVER 0.32 → 1.3, flat (the official fly offset is one model-independent Vector3(0, 0.35, 0) in the client's character space, whose unit is the standard battle-prefab scale 0.27; an official screenshot measures 1.2–1.4 tiles); enemyModelScaleY multiplies the skeleton's Y scale and the head (bar) height, also in the impostor path; a mirrorX model flips on top of the direction flip. The body, its HP bar, damage numbers, projectile hits and skill rings ride `hover`, the shadow stays on the ground; one camera drives the units over the 2D and the 3D board. - tools/build-data.mjs MODEL_STRETCH_Y / MIRRORED_PREFABS → enemies.json modelScaleY / mirrorX; data/enemies.json regenerated offline here (the PR's one-line blob was not taken) — JSON compare: only enemy_1112_emppnt / enemy_1112_emppnt_2 modelScaleY 1.263 and enemy_1196_msfyin mirrorX true. - tools/local-extract/enemy_model_offsets.py (optional, host-side prefab reader) and docs/research/12-flying-visuals-official.md (without the claim that the owner said 「都要」 on 2026-10-05 — the owner's decision of 2026-10-06 is cited; where the quirk enemies appear corrected against 0.2.0's data: 帝国炮火先兆者 is a FLY special enemy of the mode, 帝国炮火中枢先兆者 comes with the bounty 战术特训·飞行III, 木制瑞印 is in no wave). No screenshot or extracted art committed. Tests (from PR #211): test/render/unitview.test.js (FLY_HOVER ≈ 0.35 / 0.27; the same lift for every flyer; a flying view lifts by FLY_HOVER — added here: its body and HP bar ride the lift, its shadow stays on the ground), test/render/playtest6-enemies.test.js (the data keys; the 1.263 aspect and bar height; the mirrored model's x sign); both fail without the renderer change; the 425 render unit tests pass. Docs: DATA §9, ASSETS, design client.md, research 00 / 12, DESIGN §25.13.5 and the §25.10 table. Golden unchanged (drawing only; the sim reads neither field).
This commit is contained in:
1 parent
4e58481706
commit
66ae86b969
12 files changed
+703
-17
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963,"attackAnim":{"clip":"Attack","dur":1,"hit":0.6}},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111,"attackAnim":{"clip":"Attack","dur":1,"hit":0.167}},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","staticBody":true,"modelScale":0.7407,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","staticBody":true,"modelScale":0.8148,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRawLine truncated
|
||||
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963,"attackAnim":{"clip":"Attack","dur":1,"hit":0.6}},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111,"attackAnim":{"clip":"Attack","dur":1,"hit":0.167}},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","staticBody":true,"modelScale":0.7407,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","staticBody":true,"modelScale":0.8148,"attackAnim":{"clip":"Attack","dur":1,"hit":0.267}},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRawLine truncated
|
||||
+2
-1
@@ -287,7 +287,8 @@ is not known) keep their manifest clips.
|
||||
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.
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
@@ -368,6 +368,8 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
|
||||
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
|
||||
| `staticBody` | `true` (`enemy_1005_yokai` 妖怪) | **静态刚体 only** (28 keys: every air unit of the mode except “炎佑” — 妖怪 ×3, 御4, 暴鸰, 法术大师 ×2, 寒霜, 帝国炮火先兆者 ×2, 枯朽之种, 枯朽萃聚使徒, 护障 ×2, 远眺, 愧悔魂灵圣杯, 假想敌:黑云, “斩胄之剑”, “破胄之锤”, 刺胄之弹, 未装配刀片, 防护背心, 冲击式施术单元, 节日气球, “萨科塔之翼 / 之眼 / 昂首” — plus the ground boss 盐风主教昆图斯): pushes and pulls never move it, the skills still hit it (sim `Battle._displaceable`; player report after 0.1.0). PRTS 特殊机制 静态刚体: "该单位的Unity刚体的刚体类型为部分静态(Kinematic)或静态(Static)…无法产生任何速度或移动…※是否为静态刚体与单位的行动方式无关". Not in the game tables (the rigidbody lives in the prefab): `tools/build-data.mjs STATIC_BODIES` by key, from the 天赋 "{{特殊机制|静态刚体}}" of each enemy's PRTS page (every enemy of the file, read 2026-10-03); absent = a dynamic body |
|
||||
| `modelScale` | `0.5926` (`enemy_1005_yokai_3` 威龙) | official drawn size of the enemy's Spine model relative to the standard (user playtest #6): the battle prefab's transform scale down to its Spine renderer (Graphic × FaceSwitcher × Spine; SkeletonDataAsset.scale is 0.01 for every enemy skeleton) ÷ 0.27, the standard of 1454 of the client's 2147 enemy prefabs (2080 have exactly one Spine renderer) and of the operators' battle skins. The renderer multiplies `UNIT.modelScale` by it (render/units.js `enemyModelScale`). 125 keys (122 prefabs) carry one, from 0.5926 (威龙 0.16; 妖怪 0.7407, 寒霜 0.6667) to 2.2222 (青铜镜 / 青瓷茶器 0.6); absent = 1. Not in the game tables: `tools/build-data.mjs MODEL_SCALES` by `prefabKey`, read from the local client by `tools/local-extract/enemy_scales.py` |
|
||||
| `modelScaleY` | `1.263` (`enemy_1112_emppnt` 帝国炮火先兆者) | **vertically stretched models only** (2 keys, the 先兆者 pair): the official prefab's `Graphic` scale sy ÷ sx — 1.263 because the pair's scale is (0.19, 0.24, 0.24), i.e. the game draws them 26 % taller than `modelScale` (which only carries the horizontal product) implies. The renderer multiplies the skeleton's **Y** scale (and the bar height) by it (render/units.js `enemyModelScaleY`). Not in the game tables: `tools/build-data.mjs MODEL_STRETCH_Y`, read from the local client by `tools/local-extract/enemy_model_offsets.py` (a sweep of all 242 readable enemy prefabs found no other non-uniform one), docs/research/12 §3.1 (PR #211 by @xcdoge; the owner's decision of 2026-10-06) |
|
||||
| `mirrorX` | `true` (`enemy_1196_msfyin` 木制瑞印) | **mirrored models only** (1 key): the official prefab's `Graphic` X scale is negative (−0.4), so the game draws the authored model flipped; the size pipeline takes `abs(sx)`, and the renderer flips this model on top of the usual direction flip (render/units.js `mirrorX`). Same extraction as `modelScaleY` (`tools/build-data.mjs MIRRORED_PREFABS`) |
|
||||
| `attackAnim` | `{"clip":"Attack","dur":1,"hit":0.533}` (`enemy_1019_jshoot` 隐形弩手) | the enemy's attack clip — the one the client plays for its attacks (data/assets.json `anims.attack.loop` of its model, not an Idle stand-in) —, its length (s) and first strike frame (`hits`, the OnAttack event; absent when the clip has none). An unblocked ranged enemy stands for this clip at each attack (sim/ai.js `attackStand`, GitHub #58), and every enemy attack strikes at `hit` after its swing starts — a stun before it cuts the swing (sim/ai.js `attackWindup`, 0.2.0). 213 keys; absent = no attack clip known (御4, 寒霜, the 岁 relics …: the sim keeps `ATTACK_PAUSE`). Not in the game tables: `tools/build-data.mjs enemyAttackAnim` reads the committed asset manifest (tools/fetch-assets.mjs, from the Spine skeletons) |
|
||||
| `attackMoves` | `true` | **「不停止移动」 attackers only**: the handbook ability text says it attacks on the move (“十字路口”量产型's 四向攻击) — it never stops to attack (sim/ai.js `attackStand`). No enemy of the mode has it (build-data `attacksOnTheMove`) |
|
||||
|
||||
|
||||
@@ -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, shadow sprite), 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' 重生, 守墓石像; 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 —, 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' 重生, 守墓石像; 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+35
-3
@@ -23,7 +23,8 @@ what stays [ASSUMED] and the questions left to the owner → §25.10. The closin
|
||||
2026-10-06 (望's stock, skill triggers through summon areas, the 突袭 landing, GitHub #148) and the items the sources
|
||||
settled (泥岩's 闭锁; 忍冬, 深靛, 阿) → §25.11; release packaging and the asset manifest (the 自选 voices, the summons
|
||||
with no Spine model) → §25.12; round 18 — 拟态物质 at the copy cap (GitHub #207), 华法琳's 7 / 14 (GitHub #175, PR #192),
|
||||
a merged elite 拉普兰德 as a new one (GitHub #169), the leader pool per player alive (PR #209) → §25.13.
|
||||
a merged elite 拉普兰德 as a new one (GitHub #169), the leader pool per player alive (PR #209), the flying lift and two
|
||||
model quirks (PR #211) → §25.13.
|
||||
|
||||
Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1);
|
||||
2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
|
||||
@@ -34,7 +35,8 @@ does not own shows as its stand-in everywhere (the owner's recall of the officia
|
||||
kept (§25.7.2); 望's placed 棋子 count in her stock, skills that act through summons cast on an enemy in a summon's area,
|
||||
a melee 突袭 member lands where it can block (§25.11); 华法琳's granted trait at the data's 7 / 14, replacing the 12 / 24
|
||||
lock (§25.13.2); a newly merged elite 拉普兰德 counts as a new 拉普兰德 (§25.13.3); the leader pool = bloodPoint × the players
|
||||
alive at the fight's start, adopting PR #209 and replacing 「保持固定血量」 (§25.13.4).
|
||||
alive at the fight's start, adopting PR #209 and replacing 「保持固定血量」 (§25.13.4); flying units at 1.3 tiles and the two
|
||||
model quirks of PR #211 (§25.13.5).
|
||||
|
||||
### 25.1 Refactor and tooling: the big files split, golden results, lint, types, import boundaries (refactor plan; owner's decision of 2026-10-04: no web framework) — `server/http/`, `server/match/match/`, `server/match/player/`, `server/sim/battle/`, `content/enemies/`, `kits/ops/`, `public/js/ui/gameLogic/`, `render/fx/`, `render/app/`, `screens/game/`, `tools/golden.mjs`, `tools/check-imports.mjs`, `eslint.config.js`, `jsconfig.json`
|
||||
|
||||
@@ -665,6 +667,7 @@ its tile, §18.1; §25.9.2). Where a 0.1.4 change met 0.2.0 work:
|
||||
| 华法琳's granted trait stops at the data's 7 / 14 (PRTS 3/27: lowered from 12 / 24; GitHub #175, PR #192, §25.13.2) | the owner, 2026-10-06 (replaces the 12 / 24 lock) |
|
||||
| A newly merged elite 拉普兰德 is a new 拉普兰德: +8 on its first manual refresh of the round (GitHub #169, §25.13.3) | the owner, 2026-10-06 (was [ASSUMED]: the copies' count carried) |
|
||||
| The leader pool = bloodPoint × the players alive when the fight starts, solo × 1 (PR #209, §25.13.4) | the owner, 2026-10-06 (replaces 「保持固定血量」; config restores it) |
|
||||
| Flying units hover 1.3 tiles; 帝国炮火先兆者 ×2 drawn 1.263× taller, 木制瑞印 mirrored (PR #211, §25.13.5) | the owner, 2026-10-06 |
|
||||
|
||||
Settled by the sources in W5V, no longer questions: 泥岩 S3's dormancy is 闭锁 — she lets go and blocks nobody (PRTS,
|
||||
§25.11.4); 阿 S2 / S3 keep the data's DEFAULT, 忍冬 S2's and 深靛 S1's notes are what the kits do (§25.11.5).
|
||||
@@ -837,7 +840,7 @@ answers and checked the remaining items against PRTS and the client (W5V's commi
|
||||
orphans compared case-insensitively), `test/assets.test.js` (the empty-array rule). Docs: docs/DEPLOY.md §7, the
|
||||
README download section (full and lite), docs/ASSETS.md.
|
||||
|
||||
### 25.13 Round 18: GitHub #207, #175, #169 and the community PRs #192, #209, #211 (W5Y; the owner's decisions of 2026-10-06) — `server/match/builtinMeta.js`, `server/match/bot.js itemTarget`
|
||||
### 25.13 Round 18: GitHub #207, #175, #169 and the community PRs #192, #209, #211 (W5Y; the owner's decisions of 2026-10-06) — `server/match/builtinMeta.js`, `server/sim/content/garrisons/battle.js`, `server/match/player/acquire.js`, `server/match/gamedata.js bossPoolShareOf`, `render/units.js FLY_HOVER`
|
||||
|
||||
The round-18 triage of the open issues and pull requests left three bugs and three community changes for 0.2.0; the
|
||||
owner decided the open questions on 2026-10-06. Each item is one commit (W5Y) naming the golden scenarios it moved; a
|
||||
@@ -946,3 +949,32 @@ ported PR credits its author there and here.
|
||||
line), `test/match/playtest6-bosspool.test.js` (its 5 % knob halved to keep the two-player pool's size: that test is
|
||||
about the pool's arithmetic). Docs: PLAYING §7, DATA (`bossHpScale`), META, BALANCE, SIM, the
|
||||
design (engine.md 限伤, network.md boss rounds), the §20.9 / §20.10 records, research 00 / 01 / 08.
|
||||
|
||||
#### 25.13.5 Flying units hover 1.3 tiles; the 帝国炮火先兆者 pair is drawn 1.263× taller and 木制瑞印 mirrored (PR #211 by @xcdoge; the owner's decision of 2026-10-06) — `render/units.js FLY_HOVER / enemyModelScaleY` (the mirror flip), `tools/build-data.mjs MODEL_STRETCH_Y / MIRRORED_PREFABS`, `tools/local-extract/enemy_model_offsets.py`, docs/research/12-flying-visuals-official.md
|
||||
|
||||
- **Seen** (player report of 2026-10-05, PR #211): 无人机等飞行单位位置明显偏低 — every flyer drew ~1 tile too low; the two
|
||||
妖怪 drones had their art under the tile (the flat lift was 0.32 tiles).
|
||||
- **Official** (read from the client by the PR's author, research 12): the fly offset is one model-independent constant,
|
||||
`Vector3(0, 0.35, 0)` set by `CharacterAnimator`'s constructor and added while flying; 0.35 is in the client's
|
||||
character space, whose unit is the standard battle-prefab scale 0.27 — 0.35 / 0.27 ≈ 1.3 tiles, and an official
|
||||
screenshot measures 1.2–1.4. The battle prefabs carry no per-model vertical correction for flyers. The same prefab
|
||||
sweep found two model quirks: the 帝国炮火先兆者 pair's `Graphic` scale (0.19, 0.24, 0.24) (sy / sx 1.263) and
|
||||
木制瑞印's negative X scale (a mirrored model).
|
||||
- **Decision**: the owner's decision of 2026-10-06 — take all of it (the lift and both quirks).
|
||||
- **Now** (ported by hand; 0.2.0 regenerated its own data/enemies.json offline — the JSON compare shows only
|
||||
`enemy_1112_emppnt.modelScaleY`, `enemy_1112_emppnt_2.modelScaleY` 1.263 and `enemy_1196_msfyin.mirrorX`; the PR's
|
||||
one-line enemies.json was not taken): `FLY_HOVER` 0.32 → 1.3, flat; `enemyModelScaleY` multiplies the skeleton's Y
|
||||
scale and the head (bar) height, also in the impostor path; a mirrored model flips on top of the direction flip. The
|
||||
body, its HP bar, damage numbers, projectile hits and skill rings ride the lift; the shadow stays on the ground tile;
|
||||
range highlights are tiles; one camera drives the Pixi units over both the 2D and the three.js board, so both boards
|
||||
show it. Research 12 ported without the PR's claim that the owner had said 「都要」 on 2026-10-05, and corrected where
|
||||
the quirk enemies appear: 帝国炮火先兆者 is a FLY special enemy of the mode, 帝国炮火中枢先兆者 comes with the bounty
|
||||
战术特训·飞行III, 木制瑞印 is in no wave of the mode. No screenshot or extracted art is committed; the extractor stays
|
||||
optional.
|
||||
- [ASSUMED]: the character-space → tile factor (1 / 0.27; the screenshot agrees within ±0.2 tiles).
|
||||
- Golden: unchanged (drawing only; the sim reads neither field). Tests (from PR #211): `test/render/unitview.test.js`
|
||||
(FLY_HOVER ≈ 0.35 / 0.27; the same lift for every flyer, the 妖怪 drones under the tile at the old 0.32; a flying view
|
||||
lifts by FLY_HOVER — added here: its body and HP bar ride the lift, its shadow stays on the ground),
|
||||
`test/render/playtest6-enemies.test.js` (exactly the two 先兆者 stretch and 木制瑞印 mirrors; the 1.263 aspect and the bar
|
||||
height; the mirrored model's opposite x sign). Docs: DATA §9 (`modelScaleY`, `mirrorX`), ASSETS (model size, flying
|
||||
units), research 00 / 12.
|
||||
@@ -18,6 +18,7 @@ Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text,
|
||||
| `06-multiplayer-ux.md` | Co-op flow and state machine, 联防, shared vs individual, shared pool, cross-player effects, emotes and broadcasts, settlement, per-screen UI layout, style guide, server notes. Addendum: TL;DR fixes, banned-operator UI. | – |
|
||||
| `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` |
|
||||
| `11-limits-official.md` | Official limits read from the client (2.7.71 il2cpp) and the community: bond layers cap at **999** per bond (`MAX_GARRISON_STACK`, `AddBondCount` = min(L + n, 999)); in boss battles outside training a single hit of **≥ 300000** on a leader (`IsBossEnemy` = an `autoChessData.bossInfoDict` enemyId) is **cancelled** ("限伤", `MAX_BATTLE_DAMAGE`); implemented in DESIGN §20.12. | `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT` |
|
||||
| `12-flying-visuals-official.md` | Official flying visuals and model placement, read from the client (PR #211 by @xcdoge; ported into 0.2.0 by the owner's decision of 2026-10-06): the fly offset is the single model-independent constant **0.35** (`CharacterAnimator..ctor` stores `Vector3(0,0.35,0)` at field +0x114; `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it while flying, negate it on landing) — in the client's *character* space, whose unit is the standard prefab scale 0.27, so **0.35 / 0.27 ≈ 1.3 tiles**, confirmed by pixel-measuring an official screenshot (1.2–1.4). `FLY_HOVER` 0.32 → **1.3**, flat (no per-model term). The same sweep found two model quirks: `modelScaleY` 1.263 (帝国炮火先兆者 pair, vertically stretched) and `mirrorX` (木制瑞印, mirrored prefab). | `public/js/render/units.js FLY_HOVER / enemyModelScaleY`, `data/enemies.json`, `tools/local-extract/enemy_model_offsets.py` |
|
||||
|
||||
## 2. Match structure
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# 12 · Official flying visuals: a flat lift of ~1.3 tiles (the client's 0.35 in character space)
|
||||
|
||||
Written 2026-10-05 by @xcdoge as research for PR #211, after a player report (「无人机等飞行单位贴图位置明显偏低」) that
|
||||
flying units sat too low, and the follow-up 「绝对不止 0.35」 with an official screenshot of 帝国炮火先兆者 over a tile.
|
||||
Ported into 0.2.0 on 2026-10-06 (W5Y, DESIGN §25.13.5) by the owner's decision of 2026-10-06, which took the PR whole —
|
||||
the lift and both model quirks (§3.1). The screenshot and the client files are not in the repository. `PRTS` has no
|
||||
published fly-height data, so the value was read from the client and then measured on screen:
|
||||
|
||||
- **`[DATA]`** the official fly offset is a **single model-independent constant, `Vector3(0, 0.35f, 0)`** —
|
||||
`Torappu.Battle.CharacterAnimator`'s constructor stores it, and `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it
|
||||
while the unit flies and add its negation when it lands (§2).
|
||||
- **`[MEASURED]`** `0.35` is in the client's own *character* space, whose unit is the standard battle-prefab scale
|
||||
**0.27** (our `enemies.json modelScale` is a multiple of it), so the lift in **tiles is 0.35 / 0.27 ≈ 1.3** — and the
|
||||
player's screenshot measures **1.2–1.4 tiles** of art-bottom clearance (§3).
|
||||
|
||||
**Implemented** the same day: `FLY_HOVER` 0.32 → **1.3**, still a flat, model-independent lift from the model's own
|
||||
origin (§6). An earlier attempt in this session (a per-model "anchor at the art bottom" term) was withdrawn — the client
|
||||
has no per-model term at all, and the screenshot agrees with a plain constant.
|
||||
|
||||
**Tags** (as in research 11)
|
||||
- **[DATA]**: read from the official client. Local Windows client 2.7.71 (Unity 2021.3.39f1, files dated 2026-08-20):
|
||||
`Arknights_Data/il2cpp_data/Metadata/global-metadata.dat` (il2cpp v29: class, field and method names, the type/method
|
||||
tables) and `GameAssembly.dll` (x86-64 machine code, disassembled with `objdump`; method addresses mapped to names
|
||||
through the `Assembly-CSharp.dll` code-gen module's method-pointer table and the metadata method tokens).
|
||||
- **[MEASURED]**: pixel-measured from the player's official screenshot (2026-10-05) — the tile pitch, the drone's rotor
|
||||
span and the body-to-shadow distance, converted with the projection of §3.
|
||||
- **[ASSUMED]**: the character-space → tile conversion (0.27) and each model's own art hang.
|
||||
- **[COMM]**: the player reports that started this.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
| # | Question | Official answer | Tag |
|
||||
|---|---|---|---|
|
||||
| 1 | Is there a fly-height constant? | **Yes: 0.35, only the Y component.** `CharacterAnimator`'s constructor sets its fly offset field to
|
||||
`Vector3(0, 0.35f, 0)`; `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it to the mount / hit transforms while the
|
||||
unit flies and add its **negation** when it lands (`m_flyMountPointOffsetApplied` tracks the state). | DATA |
|
||||
| 2 | Is it per model? | **No.** No store to that field exists anywhere in the binary except the constructor, and the offset is applied
|
||||
to the model's own root transform — every flying unit gets the same lift, and each model keeps whatever art hang its
|
||||
artist gave it. | DATA |
|
||||
| 3 | How much is that in tiles? | **≈ 1.3 tiles (`0.35 / 0.27`).** `0.35` is in the client's character space, where the unit is the standard
|
||||
battle-prefab scale **0.27**. The player's screenshot of 帝国炮火先兆者 over a tile measures **1.2–1.4 tiles**. | ASSUMED + MEASURED |
|
||||
| 4 | What did the remake get wrong? | It lifted a flat **0.32** — the right *shape* (constant, from the origin) but ~1 tile too small, so every flyer sat
|
||||
low; the big drones (妖怪 / 妖怪MKII, whose art hangs 0.38 / 0.37 tiles below the origin) even had their art bottom
|
||||
*under* the tile. Now `FLY_HOVER = 1.3`, flat. | DATA + MEASURED |
|
||||
|
||||
---
|
||||
|
||||
## 1. How the addresses were found (reproducibility)
|
||||
|
||||
No disassembler, no Il2CppDumper — the method table was read directly from the binary and metadata.
|
||||
|
||||
1. **Metadata, self-calibrated** (`global-metadata.dat`, il2cpp v29). The struct sizes were calibrated empirically rather
|
||||
than assumed: `Il2CppTypeDefinition` = **88 bytes** (type token at +84, hi-byte 0x02, 100% of types; `method_count`
|
||||
at +64 and `field_count` at +68 — the u16 sums equal the method/field table sizes), `Il2CppMethodDefinition` = **32
|
||||
bytes** (name index at +0, `declaringType` at +4, method token at +20; the first token is `0x06000001`). Sanity-checked
|
||||
on `Torappu.Battle.AutoChessBattleConst` (field names match research 11's list; floats read correctly, e.g.
|
||||
`BATTLE_WAIT_UI_TIME = 1.5`, `RATIO_PARAM = 100`). (Integer constant values are compressed in v29 and not needed here.)
|
||||
2. **Code-gen module** (`GameAssembly.dll`). The `Il2CppCodeGenModule` for `Assembly-CSharp.dll` was located through the
|
||||
string: find `"Assembly-CSharp.dll"`, convert its file offset to an RVA, then find a pointer to that RVA — that is the
|
||||
struct's `moduleName` field. For this build: `moduleName` VMA 0x186e69af0 (file+0x6e67ef0), **`methodPointerCount`
|
||||
= 217790**, **`methodPointers` = 0x188216460** (file+0x8214860, in `.data`).
|
||||
3. **Method index → address**. The metadata method token's RID − 1 is the index into `methodPointers`. So
|
||||
`_SetFlyMountPointOffset` (token 0x600F5DA, RID 62938) → index **62937** → pointer **0x180600050**;
|
||||
`_SetFlyHitOffset` → **0x1805fff30**; `_OffsetHitTransform` → **0x1805ffdf0**; `..ctor` → **0x180600530**.
|
||||
4. **Disassembly** with `objdump -d -j il2cpp --start-address=… --stop-address=… GameAssembly.dll`. The `il2cpp`
|
||||
section (VMA 0x18053c000, 0x653a05c bytes) holds the real x86-64 code. (The binary has Tencent's `UnityPlayer.dll.tvmp`
|
||||
and odd `.Sgxm0/.Sgxm1` sections, so the export table is unhelpful; the module-name route above avoids it.)
|
||||
|
||||
---
|
||||
|
||||
## 2. The fly offset: one constant, 0.35 (Y only) [DATA]
|
||||
|
||||
### 2.1 `CharacterAnimator..ctor` sets it (VMA 0x180600530)
|
||||
|
||||
```
|
||||
movss xmm1, [0x186a78a50] ; 0.35f (0x3EB33333)
|
||||
xorps xmm0, xmm0
|
||||
unpcklps xmm0, xmm1 ; xmm0 = { x=0.0, y=0.35 }
|
||||
xorps xmm2, xmm2 ; z = 0.0
|
||||
movsd [rbx+0x114], xmm0 ; field @ +0x114 = Vector3(0, 0.35, 0) (x,y)
|
||||
movss [rbx+0x11c], xmm2 ; (z)
|
||||
```
|
||||
|
||||
So the instance field at **+0x114** is a `Vector3(0, 0.35, 0)` for every `CharacterAnimator`.
|
||||
The constant sits in `.rdata` at **0x186a78a50 = 0x3EB33333** (= 0.3499999940395355), and the next float is an unrelated
|
||||
1.8f.
|
||||
|
||||
### 2.2 `_SetFlyMountPointOffset` / `_SetFlyHitOffset` apply it
|
||||
|
||||
Both are structurally identical twins; `_SetFlyMountPointOffset` (VMA 0x180600050) shown:
|
||||
|
||||
```
|
||||
cmp [rbx+0x121], dl ; m_flyMountPointOffsetApplied == (arg) ? → return
|
||||
je ret
|
||||
test dl, dl
|
||||
je .notFlying
|
||||
movsd xmm6, [rbx+0x114] ; the Vector3(0, 0.35, 0)
|
||||
mov esi, [rbx+0x11c]
|
||||
jmp .apply
|
||||
.notFlying:
|
||||
... movss xmm1, [0x186a77e00] ; −0.0 sign mask (0x80000000)
|
||||
... xorps xmm1 (x/y/z) ; negate all three components
|
||||
.apply:
|
||||
call _OffsetHitTransform([rbx+0xf8], &offset, 0)
|
||||
call _OffsetHitTransform([rbx+0x100], &offset, 0) ; a second transform
|
||||
mov [rbx+0x121], dl ; m_flyMountPointOffsetApplied = arg
|
||||
```
|
||||
|
||||
- Flying (`arg != 0`): add the offset. Landing / not flying: add the **negation** (sign flipped through the `−0.0` mask
|
||||
at **0x186a77e00 = 0x80000000**), i.e. remove it.
|
||||
- It touches **two** child transforms (`+0xf8`, `+0x100` — the mount point and the hit transform, both via
|
||||
`_OffsetHitTransform(transform, &offset, false)`). `_SetFlyHitOffset` is the same, guarded by a different static flag
|
||||
and a different member (`[r8+0x160]` vs `[r8+0x158]`).
|
||||
- The only direct caller of `_SetFlyMountPointOffset` is `OnFinish` (#62924, call site 0x1805feb87) — the "land" path.
|
||||
|
||||
### 2.3 Reading
|
||||
|
||||
- **One constant, all flyers, no per-model term.** A section-wide scan for **any** store to
|
||||
`[reg+0x110/0x114/0x118/0x11c]` (SSE + GPR opcodes, with the ModRM `[reg+disp32]` form, in every executable section)
|
||||
found **no writer except the constructor** — nothing scales or rewrites the offset per model.
|
||||
- **The offset is a plain local position at the model's root.** `_OffsetHitTransform` gets the vector by pointer and is
|
||||
called with two of the animator's own transforms, so the whole model moves up by one constant; where each model's art
|
||||
sits inside that space is the artist's business (妖怪's art hangs 0.38 tiles below its origin, 帝国炮火先兆者's art sits
|
||||
entirely above it).
|
||||
- **The unit is not the tile.** 0.35 read as "tiles" is wrong: an offset applied at the model root in the client's
|
||||
character space is scaled by that space's unit — the **standard battle-prefab scale 0.27** (the same 0.27 our
|
||||
`enemies.json modelScale` is a multiple of). `0.35 / 0.27 = 1.296` tiles, and the screenshot (§3) measures 1.2–1.4.
|
||||
[ASSUMED: the exact hierarchy that produces the 1/0.27 factor; the two independent numbers agree, and the on-screen
|
||||
measurement is what the remake has to match.]
|
||||
|
||||
---
|
||||
|
||||
## 3. Measuring the official on screen [MEASURED]
|
||||
|
||||
Source: the player's own screenshot of 帝国炮火先兆者 hovering over a tile (2026-10-05; a 468×510 normalised copy; JPEG).
|
||||
Measured with Pillow, on the tile grid and on the sprite itself.
|
||||
|
||||
| What | Value | How |
|
||||
|---|---|---|
|
||||
| Tile pitch, horizontal | ≈ 180–200 px | hazard-stripe bands (olive ≈ RGB 110,110,95) at x ≈ 202 and 382 on every row |
|
||||
| Tile pitch, vertical | **150 px** | stripe bands at y ≈ 306.5 and 456.5 on the same column |
|
||||
| Drone rotor span | 313 px | dark connected component (max channel < 95), x 60..372 |
|
||||
| Drone art width, world | 1.561 tiles | skeleton `bounds.width` 710 × `modelScale` 0.7037 / 320 (`UNIT.modelScale`) |
|
||||
| ⇒ scale | **≈ 200 px per tile** | 313 / 1.561 — agrees with the 180 px stripe pitch within 10 % |
|
||||
| ⇒ tile tilt | cos θ ≈ 0.75, **sin θ ≈ 0.66** | cos θ = 150 / 200 (a ground tile's screen height vs its width) |
|
||||
| Drone art bottom | y ≈ 223 (dark shell) … 250 (pod bottom) | dark component bottom / the pod's visible bottom edge |
|
||||
| Shadow centroid | y ≈ 410 | the dark ellipse on the tile, x 157..404, y 392..428 |
|
||||
| ⇒ screen gap | **160–187 px** | art bottom → shadow |
|
||||
| ⇒ **hover** | **1.2–1.4 tiles** | h = Δy / (s · sin θ) = 160…187 / (200 × 0.66) |
|
||||
|
||||
Cross-checks: the client's own constant in character space gives 0.35 / 0.27 = **1.30** tiles — inside the measured
|
||||
range; and the remake's old flat 0.32 would put the same drone only ≈ 42 px above its shadow at that zoom (0.32 × 200 ×
|
||||
0.66), roughly a quarter of what the official shows, which is exactly what the report described.
|
||||
|
||||
Caveats: the art bottom is a matter of a few pixels (the pod is pale against the sky), the shadow is a soft ellipse, and
|
||||
the drone's own `modelScale` is taken from our data — the estimate is ±0.2 tiles, not a precise constant. The lift is
|
||||
**not** read from the shadow's *pixel* offset alone: the shadow marks the unit's ground point (the official draws flying
|
||||
units' shadows on the ground tile), which is what makes it a usable reference.
|
||||
|
||||
### 3.1 What decides a model's own offset (read from the `.skel` bones) [DATA]
|
||||
|
||||
The per-model part of "where the art sits on the tile" is **the Spine authoring, and nothing else**: where the artist put
|
||||
the skeleton's origin and how far the drawn art extends from it. The runtime only adds the one constant (§2). Read from
|
||||
the shipped `.skel` files with the project's own parser (`@pixi-spine/runtime-3.8`, setup-pose bone Y in skeleton units,
|
||||
origin = the root bone at y = 0):
|
||||
|
||||
| enemy | bones | lowest bone (setup) | art `bounds.y` | ⇒ net art hang |
|
||||
|---|---|---|---|---|
|
||||
| 妖怪 | 11 | `Fan_Stop_01` −145.1 (rotor); `Hip`/`root` ≈ 0 | **−162.7** | 0.377 tiles below the origin |
|
||||
| 妖怪MKII | 11 | same skeleton as 妖怪 | −145.4 | 0.370 (its `modelScale` 0.8148 vs 0.7407 is the only difference) |
|
||||
| 暴鸰 | 10 | `Fan_Stop_01` −130.5, `L_Leg` −16.9, `Hip` −0.1 | −107.7 | 0.249 |
|
||||
| 寒霜 | 6 | `Fan_Stop_01` −57.6 (only 6 bones; `Hip` +70) | −32.9 | 0.068 |
|
||||
| 帝国炮火先兆者 | 15 | `C_R_Support_Down` −158.2, `C_R_propeller` −89.1 | **+0.16** | **none** — nothing is *drawn* below its origin (the gear bone is below it, but the setup-pose attachment bounds start just above 0) |
|
||||
| 御4 | 8 | `C_Fan_A` −135.9, `C_Belt` −34.9 | +9.3 | none (art starts above the origin) |
|
||||
|
||||
So: same-code constant + the artist's pivot/attachment layout = every visible difference between models. Two consequences
|
||||
worth keeping in mind:
|
||||
|
||||
- A model whose art hangs below its origin keeps that hang in the official too (妖怪 flies with its rotors ≈ 0.92 tiles
|
||||
up, 帝国炮火先兆者 with its pod ≈ 1.30) — the remake now reproduces both, because it uses the same skeletons and the
|
||||
same constant.
|
||||
- The client *does* hold a per-model positional table, `Torappu.Battle.BakedMountPointData` (`bakedStepInterval`,
|
||||
`frameCount`, `trsData` — per-frame TRS), read through `CharacterAnimator.GetMountPoint` /
|
||||
`get_footTransform` / `get_shadowTransform` / `get_hitTransform`. Those are the **mount points** (hit box, shadow decal,
|
||||
muzzle, foot), i.e. per-model anchors for effects and gameplay — not a repositioning of the drawn body.
|
||||
- **Open question:** our renderer anchors every model by its origin, which equals "feet on the tile" for almost every
|
||||
model (ground units' median `bounds.y` = 0.013 tiles). The one visible outlier is the boss 盐风主教昆图斯 (0.99 tiles
|
||||
below its origin): *if* the official instead anchors bodies by the baked foot mount point, it would stand correctly while
|
||||
ours sinks. Distinguishing the two needs an official screenshot of that boss standing on a tile; until then the
|
||||
origin-anchoring rule (and this flat lift) is what the flying fix relies on.
|
||||
|
||||
Follow-up (2026-10-05, after the question "具体模型偏移位置到底受什么决定" and an official screenshot of 盐风主教昆图斯):
|
||||
|
||||
- The client's other per-model vertical value is **`CharacterAnimator.UpdateBaseline` → `host.get_graphicHeight()` →
|
||||
`ApplyBaselineEffect(0, −1, graphicHeight, 0)` → `SetFloatParam(<param>, −graphicHeight)`** (method pointers 0x1805ff880,
|
||||
#63458 `get_host`, #76677 `get_graphicHeight` — a *virtual* getter, so the value comes from the concrete entity's data —
|
||||
#63382 `ApplyBaselineEffect`, #63369 `SetFloatParam`; the height is negated through the same `−0.0` mask at 0x186a77e00).
|
||||
It reaches a **render/effect float parameter**, not a body transform: the model's height above its baseline is handed to
|
||||
the shader/effect layer (bars, effects, ground-fade), while the drawn body stays where the unit's transform puts it. Our
|
||||
data has no `graphicHeight` field; the renderer derives the equivalents itself (`bounds.y` for the art hang, `bounds.height`
|
||||
for the head/bar height).
|
||||
- The boss check — **no repositioning, and no hole either.** 盐风主教昆图斯 (`enemy_1521_dslily`, `modelScale` missing → 1,
|
||||
383 bones, art 9.86 × 5.09 tiles, art bottom **0.99 tiles below the origin**, and no hole/pit region among its 113 atlas
|
||||
regions). Facts from our own data:
|
||||
- the boss's route (`waves.json act1autochess_h07_04`) is `move → [1,17] → **disappear** → wait 3 s → **appear [5,10]**`
|
||||
— it leaves the field and re-enters;
|
||||
- the wave's `devices` carry 4 **hidden `trap_039_dstnta`** at (5,9) / (5,11) / (3,9) / (3,11) — the spawn points of the
|
||||
tentacles its `SummonTentac` skill calls up (`dslily_dstnta` branches; `server/sim/content/bosses.js`).
|
||||
**Withdrawn interpretation:** an earlier pass read the grey area under the boss in the player's screenshot as a *hole in
|
||||
the ground*, and proposed drawing one. Checked against the official game (by the PR's author): **there is no such hole,
|
||||
and the model has none either** (`hidden: true` devices draw nothing at match start; the grey area is the boss's own art/shadow over the tiles).
|
||||
So the boss's art simply extends ~1 tile below its position as big-monster art overlapping the tiles in front — which is
|
||||
exactly what origin-anchoring draws. **Origin-anchoring stands, no per-model body lift, and no `bossSpawn` decal is
|
||||
missing** (the client's device set omitting `bossSpawn` — `render/tiles.js DEVICE_ROLES`, `render/board3d/layout.js
|
||||
DEVICE_KINDS` — matches the official, which draws nothing there).
|
||||
- Follow-up on the boss's **size** — verified, nothing to change. Our `enemies.json` has no `modelScale` for 盐风主教昆图斯,
|
||||
and our data omits that field exactly when the official prefab's scale product is the standard 0.27 (`tools/build-data.mjs`
|
||||
`MODEL_SCALES` lists only the deviations). Re-ran the host-side extractor against the local client
|
||||
(`python tools/local-extract/enemy_scales.py --game <…>/StreamingData/AB/Windows --json`, UnityPy + lz4 in a `.venv`):
|
||||
**`enemy_1521_dslily = 0.27`** — the standard — so `modelScale` 1 is correct and the boss is drawn at the right size.
|
||||
The same run reproduced the whole table for all 243 prefabs with **0 differences** from the hardcoded `MODEL_SCALES`,
|
||||
which is a clean end-to-end check of the size pipeline. (Re-runnable any time with that script; no dump committed.)
|
||||
- **Do the official prefabs move a model vertically per model? No.** Asked as 「按原版偏移来修正」 after a mid-session
|
||||
misreading, this was settled by reading the battle prefabs directly (`tools/local-extract/enemy_model_offsets.py`,
|
||||
UnityPy; `dyn/battle/prefabs/enemies/<prefab>.prefab` in `battle/enm_pfb_*.ab`):
|
||||
- The **prefab root's** local position is an editor leftover that the game overwrites with the unit's world position at
|
||||
instantiation (妖怪 (0,0,0) but 帝国炮火先兆者 (4.23, 2.11, −1.2), 源石虫 (3, 3, 0), 掠海漂移体 (2.21, 5.23, 0)) —
|
||||
reading it as a height was the misreading, and it is what produced the bogus "+2.11 world units" for the drone.
|
||||
- The real placement is the **`Graphic` node** (the wrapper that holds `FaceSwitcher/Spine`): for all **28** of this
|
||||
mode's flyers its local position is **0** — ten at (0,0,0), the other seventeen at the same **(0, −0.2, −0.06)** that
|
||||
ground units such as 源石虫 use (a prefab-family convention, unrelated to `bounds.y`).
|
||||
- So the client applies **no per-model vertical correction to flyers** — flat `FLY_HOVER` is the client's own behaviour,
|
||||
and a model whose art hangs below its pivot keeps that hang on screen. The art-bottom compensation that was briefly
|
||||
implemented in this session is **withdrawn** (code back to a flat `FLY_HOVER = 1.3`, tests back to asserting the same
|
||||
lift for every flyer).
|
||||
- One genuine deviation did surface, and it is a *size*, not a position: 帝国炮火先兆者 and 帝国炮火中枢先兆者 have a
|
||||
**non-uniform** `Graphic` scale — **(0.19, 0.24, 0.24)**, i.e. the official draws them **1.263× taller** than the
|
||||
uniform 0.19 our `enemies.json modelScale` (0.7037) implies. Our renderer had no per-axis model scale, so those two
|
||||
were drawn ~21 % too short vertically — **now fixed**, see the sweep's last bullet.
|
||||
- **Full sweep of all 243 enemies** (`enemy_model_offsets.py --json`):
|
||||
- `sy/sx ≠ 1` in exactly **3** of 242 readable prefabs: the two 先兆者 above (**+1.263**, a real vertical stretch) and
|
||||
`enemy_1196_msfyin`, whose ratio is **−1.000** because its `Graphic` X scale is **negative** (sx −0.4, sy 0.4) — the
|
||||
official **mirrors that model horizontally**, and our scale pipeline takes `abs()` (`enemy_scales.py` /
|
||||
`MODEL_SCALES`), so we draw it unmirrored. A facing detail, not a stretch, and it only affects that one enemy.
|
||||
- **Both are implemented** (the owner's decision of 2026-10-06, taking PR #211 whole): `enemies.json` gained
|
||||
`modelScaleY` (1.263, the two 先兆者) and `mirrorX` (木制瑞印), produced by `tools/build-data.mjs MODEL_STRETCH_Y` /
|
||||
`MIRRORED_PREFABS`; `render/units.js` multiplies the skeleton's Y scale (and the bar height) by `enemyModelScaleY` and
|
||||
flips `flip` for a mirrored model, in both the direct and the impostor draw paths. Documented in DATA.md §9; covered
|
||||
by three tests in `test/render/playtest6-enemies.test.js` (data, the 1.263 aspect + bar height, the opposite x-sign).
|
||||
Where they appear in this mode (re-checked on 0.2.0's data): 帝国炮火先兆者 is a FLY special enemy of the second half
|
||||
(factions.json, the FLY pool and entries), 帝国炮火中枢先兆者 comes with the bounty 战术特训·飞行III (choices.json
|
||||
`enemyeffect_10_3`); 木制瑞印 is in no wave or card of the mode (the general data set).
|
||||
- `Graphic` vertical offsets are a small set of prefab-family conventions, none of them flyer-specific:
|
||||
(−0.200, −0.060) 180 enemies (16 of them FLY), (−0.202, 0) 25, (−0.200, 0) 18, (−0.450, −0.060) 4, (−0.330, −0.060) 4,
|
||||
and **(0, 0) for 11 — every one of which is a flyer**. Ground units share the −0.2 families, so that offset is the
|
||||
family's camera-facing convention, not a world-space lift of the model.
|
||||
- `modelScale` re-verified against the official `sx / 0.27` for all 242: **0 mismatches** (the shipped table is exact).
|
||||
- One prefab has no `Graphic` child at all (`enemy_9016_acstmr` 刺胄之弹) — nothing to read there.
|
||||
|
||||
---
|
||||
|
||||
## 4. The flyers of this mode, and why the old lift looked worst on the drones [DATA]
|
||||
|
||||
`bounds.y` is the art's lowest point in skeleton units (the view scales by `UNIT.modelScale = 1/320`, then by the enemy's
|
||||
`modelScale`); `sink` (tiles) = `−bounds.y / 320 × modelScale` — how far the art hangs **below its own origin**. Under a
|
||||
flat lift the *net* clearance is `FLY_HOVER − sink`, so the same constant leaves every model at a different visible
|
||||
height — the official's arrangement too.
|
||||
|
||||
| enemy | key | `bounds.y` | `modelScale` | `sink` | net at 0.32 (before) | net at 1.3 (now) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 妖怪 | `enemy_1005_yokai` | −162.7 | 0.7407 | 0.377 | **−0.06** (art under the tile) | 0.92 |
|
||||
| 妖怪MKII | `enemy_1005_yokai_2` | −145.4 | 0.8148 | 0.370 | **−0.05** (art under the tile) | 0.93 |
|
||||
| 暴鸰 | `enemy_1040_bombd` | −107.7 | 0.7407 | 0.249 | +0.07 | 1.05 |
|
||||
| 寒霜 | `enemy_1042_frostd` | −32.9 | 0.6667 | 0.068 | +0.25 | 1.23 |
|
||||
| 帝国炮火先兆者 | `enemy_1112_emppnt` | **+0.16** (art above the origin) | 0.7037 | 0.000 | +0.32 | **1.30** |
|
||||
| 帝国炮火中枢先兆者 | `enemy_1112_emppnt_2` | +0.16 | 0.7037 | 0.000 | +0.32 | 1.30 |
|
||||
|
||||
All **28** flying units in this mode's data (`stats.motion === 'FLY'`) are lifted by `FLY_HOVER`, in three groups that no
|
||||
longer matter to the code: 9 whose art hangs below the origin (枯朽之种 0.386, 妖怪 0.377, 妖怪MKII 0.370, 威龙 0.269,
|
||||
暴鸰 0.249, 寒霜 0.068, 法术大师A1/A2 0.049, 愧悔魂灵圣杯 0.003), 6 whose `bounds.y ≥ 0` (御4, the two 先兆者, 枯朽萃聚
|
||||
使徒, 护障 / 护障·P) and 13 with no usable `bounds` in their `.skel` header (远眺, 节日气球, the 萨科塔 and 斩胄之剑 / 破胄
|
||||
之锤 / 刺胄之弹 parts…). The flat lift is what the client does, so those three groups need no special case.
|
||||
|
||||
Ground units are unaffected: their median `bounds.y` sink is ≈ 0.013 tiles. (A separate oddity, not touched here: the boss
|
||||
盐风主教昆图斯 sinks ≈ 0.99 tiles; body alignment is a broader change and not part of this report.)
|
||||
|
||||
### 4.1 近地悬浮是地面怪, not a low-flying tier [DATA]
|
||||
|
||||
Asked as "所有飞行敌方(非近地飞行)都是 +1.3 是吗" — **yes, and "近地悬浮" is not a flyer at all**: the official's own
|
||||
data classifies those units as ground (`motion: WALK`), so they never see the lift. The two units whose descriptions say
|
||||
it outright:
|
||||
|
||||
| enemy | 描述 | `motion` | our lift | the "slight hover" look comes from |
|
||||
|---|---|---|---|---|
|
||||
| 掠海漂移体 `enemy_2025_syufo` | 「【近地悬浮】,攻击额外造成侵蚀损伤」 | **WALK** | **0** | its art: `bounds.y = +6.7` skel units, i.e. drawn a hair *above* its pivot |
|
||||
| 吉兆飞鳞 `enemy_10045_parrot` | 「近地悬浮,失去近地悬浮后晕眩数秒重新起飞…」 | **WALK** | **0** | (no `bounds` in the manifest) |
|
||||
|
||||
Consistency of the classification across the whole roster (249 enemies of this mode): every one of the **13** units whose
|
||||
description carries the official 【飞行单位】 tag is `motion: FLY` (13/13), and no unit with 悬浮/浮空/漂浮/漂移 in its
|
||||
description is `FLY` (0/2). So the tier boundary is data, not code — a "near-ground hoverer" is a ground enemy that the
|
||||
artist drew slightly above its pivot, while a flyer is one the code lifts by the single constant. The same principle
|
||||
explains every other per-model difference (§3.1): **the code never repositions a model relative to its origin**.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. Before, official, now
|
||||
|
||||
| | Remake before | Official | Remake now (§6) |
|
||||
|---|---|---|---|
|
||||
| Lift | flat **0.32** from the origin | flat **0.35** in character space = **≈1.3 tiles** from the model root | flat **1.3** from the origin |
|
||||
| Model dependence | none | none (one constant) | none (one constant) |
|
||||
| 妖怪 net art clearance | −0.06 tiles (art under the tile) | ≈ 1.3 − 0.38 = 0.92 | 0.92 |
|
||||
| 帝国炮火先兆者 net | +0.32 | ≈ 1.3 | 1.30 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation (PR #211, 2026-10-05; ported into 0.2.0 on 2026-10-06 — client-only, plus two data fields)
|
||||
|
||||
- `public/js/render/units.js`: `FLY_HOVER` 0.32 → **1.3**, a flat lift used directly at the draw site
|
||||
(`const hoverTo = this.flying && this.alive ? FLY_HOVER : 0`). The doc-comment above the constant carries the full
|
||||
client citation (class, methods, VMA 0x180600555, constant 0x186a78a50 = 0x3EB33333, the −0.0 mask 0x186a77e00), the
|
||||
0.35/0.27 derivation and the screenshot measurement.
|
||||
- **Withdrawn in the same session**: an earlier version of this fix exported a `flyLift(entry, modelK)` that added the
|
||||
model's own art hang (`max(0, −bounds.y / 320 × modelScale)`) on top of `FLY_HOVER = 0.35`. The disassembly (no
|
||||
per-model term) and the screenshot (a constant ≈1.3) both say the lift is model-independent, so it was removed rather
|
||||
than kept as a second, undocumented variable.
|
||||
- `test/render/unitview.test.js`: `describe('flying units hover FLY_HOVER above the ground, whatever their model')` —
|
||||
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`).
|
||||
- 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
|
||||
the prefabs again.
|
||||
- Where the lift shows: the body, the HP bar above it, damage numbers, projectile hits and skill rings ride `hover`
|
||||
(render/units.js, render/fx/*); the shadow stays on the ground tile; range highlights are tiles. One camera
|
||||
(render/projection.js) drives the Pixi units over both the 2D atlas board and the three.js board, so both boards show
|
||||
the same lift.
|
||||
- The sim is untouched (fly height is drawing only), so no golden result moves. A hard refresh applies the client change.
|
||||
+52
-11
@@ -87,6 +87,18 @@ export function enemyModelScale(rec) {
|
||||
const k = Number(rec && rec.modelScale);
|
||||
return Number.isFinite(k) && k > 0.05 && k < 20 ? k : 1;
|
||||
}
|
||||
/**
|
||||
* Vertical stretch of an enemy's model on top of `enemyModelScale` (enemies.json `modelScaleY`, the official battle
|
||||
* prefab's `Graphic` scale sy ÷ sx; 1 when absent or unusable). `modelScale` only carries the horizontal product of
|
||||
* Graphic / FaceSwitcher / Spine, so a model whose prefab has a non-uniform scale is drawn too short without this.
|
||||
* Two enemies have one — 帝国炮火先兆者 and 帝国炮火中枢先兆者 at 1.263 (Graphic (0.19, 0.24, 0.24): the official draws
|
||||
* them 26 % taller than their width implies); a sweep of all 242 readable enemy prefabs found no other
|
||||
* (tools/local-extract/enemy_model_offsets.py, docs/research/12 §3.1; PR #211 by @xcdoge).
|
||||
*/
|
||||
export function enemyModelScaleY(rec) {
|
||||
const k = Number(rec && rec.modelScaleY);
|
||||
return Number.isFinite(k) && k > 0.2 && k < 5 ? k : 1;
|
||||
}
|
||||
/**
|
||||
* Seconds of the death clip of a manifest Spine entry (`anims.die`, else a 'Die' clip, as SpineActor.dieClip; its
|
||||
* `animations` duration), 0 when it has none — 131 of the 135 operator Back models (GitHub issue #25).
|
||||
@@ -124,8 +136,30 @@ 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. */
|
||||
export const FLY_HOVER = 0.32;
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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 =
|
||||
* 0x3EB33333; x and z are 0), and `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it to the mount / hit transforms
|
||||
* while the unit flies and add its negation when it lands (the sign flips through the −0.0 mask at 0x186a77e00). It is
|
||||
* model-independent: no store to that field exists anywhere in the binary except the constructor.
|
||||
*
|
||||
* `0.35` is that constant in the client's own (character) space, whose unit is the standard battle-prefab scale **0.27**
|
||||
* (our `enemies.json modelScale` is a multiple of it — units.js `enemyModelScale`), so the lift is **0.35 / 0.27 ≈ 1.3
|
||||
* tiles** [ASSUMED: the hierarchy that yields the 1 / 0.27 factor]. An official screenshot (帝国炮火先兆者 over a tile)
|
||||
* measures the same: the drone's art bottom sits 1.2–1.4 tiles above the ground it crosses.
|
||||
*
|
||||
* **No per-model term** — (a) the binary never rewrites the offset; (b) the battle prefabs carry no per-model vertical
|
||||
* correction for flyers (their `Graphic` node sits at local (0,0,0), or at the (0,−0.2,−0.06) their ground-unit prefab
|
||||
* family shares — unrelated to how far each model's art hangs below its pivot). So a model whose art hangs below its
|
||||
* origin keeps that hang in the official too: 妖怪 flies with its rotors ≈ 0.9 tiles up, 帝国炮火先兆者 with its art
|
||||
* bottom ≈ 1.3. It replaced a flat 0.32 (player report 2026-10-05: 无人机等飞行单位位置明显偏低 — every flyer was ~1 tile
|
||||
* too low). The shadow stays on the ground under the unit; the HP bar, damage numbers, projectile hits and skill rings
|
||||
* ride the body (`hover`); range highlights are tiles.
|
||||
*/
|
||||
export const FLY_HOVER = 1.3;
|
||||
|
||||
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
|
||||
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
|
||||
@@ -310,7 +344,13 @@ export class UnitView {
|
||||
this.isEnemy = info.side === 'enemy';
|
||||
this.isBoss = !!info.boss;
|
||||
// enemies: the official prefab's size factor (1 for operators, summons and enemies at the standard size)
|
||||
this.modelK = this.isEnemy ? enemyModelScale(ctx.lookupDef ? ctx.lookupDef(info) : null) : 1;
|
||||
const def = this.isEnemy && ctx.lookupDef ? ctx.lookupDef(info) : null;
|
||||
this.modelK = this.isEnemy ? enemyModelScale(def) : 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: 木制瑞印)
|
||||
this.modelKY = this.isEnemy ? enemyModelScaleY(def) : 1;
|
||||
this.mirrorX = this.isEnemy && !!(def && def.mirrorX);
|
||||
this.isToken = info.kind === 'token';
|
||||
this.golden = !!info.golden;
|
||||
this.tier = clamp(Number(info.tier) || 1, 1, 6);
|
||||
@@ -917,7 +957,7 @@ export class UnitView {
|
||||
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
|
||||
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
|
||||
if (this._cull(bx, by, s, dt)) return;
|
||||
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
|
||||
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);
|
||||
@@ -959,7 +999,7 @@ export class UnitView {
|
||||
} else {
|
||||
if (this.imp) this._leaveImpostor();
|
||||
this.actor.spine.alpha = this.swapT;
|
||||
this.actor.spine.scale.set(sc * flip, sc);
|
||||
this.actor.spine.scale.set(sc * flip, sc * this.modelKY);
|
||||
this.actor.update(animDt);
|
||||
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
|
||||
}
|
||||
@@ -1013,9 +1053,9 @@ export class UnitView {
|
||||
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
|
||||
// headroom × their official model factor)
|
||||
let headTiles = UNIT.headroom;
|
||||
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
|
||||
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * this.modelKY * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
|
||||
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
|
||||
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK, 0.55, 2.2);
|
||||
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK * this.modelKY, 0.55, 2.2);
|
||||
this._headTiles = headTiles;
|
||||
this.screen.top = by - headTiles * s;
|
||||
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
|
||||
@@ -1285,12 +1325,13 @@ export class UnitView {
|
||||
|
||||
_renderImpostor(sc, atlas) {
|
||||
const P = this.P, R = this.ctx.renderer, imp = this.imp;
|
||||
const yK = this.modelKY; // the vertical stretch is baked into the impostor (the flip is the sprite's)
|
||||
const box = this._impBox();
|
||||
const w = Math.max(8, Math.ceil(box.w * sc)), h = Math.max(8, Math.ceil(box.h * sc));
|
||||
const w = Math.max(8, Math.ceil(box.w * sc)), h = Math.max(8, Math.ceil(box.h * sc * yK));
|
||||
const sp = this.actor.spine;
|
||||
sp.alpha = 1;
|
||||
if (this._tint !== 0xffffff) { this._tint = 0xffffff; sp.tint = 0xffffff; }
|
||||
const ox = -box.x0 * sc, oy = -box.y0 * sc;
|
||||
const ox = -box.x0 * sc, oy = -box.y0 * sc * yK;
|
||||
if (atlas) {
|
||||
let slot = imp.slot;
|
||||
const clip = !!(this.actor.clipped && this.actor.clipOn);
|
||||
@@ -1300,7 +1341,7 @@ export class UnitView {
|
||||
if (slot && imp.rt) { imp.rt.destroy(true); imp.rt = null; }
|
||||
}
|
||||
if (slot) {
|
||||
atlas.draw(slot, sp, { a: sc, d: sc, tx: ox, ty: oy });
|
||||
atlas.draw(slot, sp, { a: sc, d: sc * yK, tx: ox, ty: oy });
|
||||
if (imp.sprite.texture !== slot.tex) imp.sprite.texture = slot.tex;
|
||||
imp.sprite.anchor.set(ox / slot.w, oy / slot.h);
|
||||
imp.sc = sc;
|
||||
@@ -1319,7 +1360,7 @@ export class UnitView {
|
||||
sp.scale.set(1, 1);
|
||||
sp.visible = true;
|
||||
const m = this._m || (this._m = new P.Matrix());
|
||||
m.set(sc, 0, 0, sc, ox, oy);
|
||||
m.set(sc, 0, 0, sc * yK, ox, oy);
|
||||
try { R.render(sp, { renderTexture: rt, clear: true, transform: m }); } catch { /* lost context etc. */ }
|
||||
if (parent === atlas?.parked) sp.visible = false;
|
||||
imp.sprite.anchor.set(ox / rt.width, oy / rt.height);
|
||||
|
||||
@@ -87,6 +87,55 @@ describe('#9 enemy models at their official prefab scale', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The official's own model quirks, read from its battle prefabs (tools/local-extract/enemy_model_offsets.py, swept over
|
||||
// all 243 enemies of the mode 2026-10-05; PR #211 by @xcdoge, taken by the owner's decision of 2026-10-06):
|
||||
// `modelScale` only carries the horizontal product of Graphic / FaceSwitcher / Spine, so two enemies are stretched
|
||||
// vertically (帝国炮火先兆者 / 帝国炮火中枢先兆者: Graphic scale (0.19, 0.24, 0.24) → sy/sx = 1.263) and one is mirrored
|
||||
// (木制瑞印: sx −0.4, sy 0.4 → the official draws it flipped). The renderer applies the stretch to the skeleton's Y scale
|
||||
// (and to the bar height) and flips `flip` for a mirrored model, on top of the usual direction flip.
|
||||
describe('official prefab quirks: vertical stretch and mirrored models', () => {
|
||||
test('data: exactly the two 先兆者 stretch, exactly 木制瑞印 mirrors', () => {
|
||||
assert.equal(E.enemy_1112_emppnt.modelScaleY, 1.263);
|
||||
assert.equal(E.enemy_1112_emppnt_2.modelScaleY, 1.263);
|
||||
assert.equal(E.enemy_1196_msfyin.mirrorX, true);
|
||||
assert.equal(E.enemy_1112_emppnt.modelScale, 0.7037, 'the horizontal product is unchanged');
|
||||
for (const [k, e] of Object.entries(E)) {
|
||||
if (e.modelScaleY != null) assert.ok(k === 'enemy_1112_emppnt' || k === 'enemy_1112_emppnt_2', `stretched: ${k}`);
|
||||
if (e.mirrorX != null) assert.equal(k, 'enemy_1196_msfyin', `mirrored: ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('UnitView: the stretched pair is 1.263× taller than wide, its bar sits proportionally higher', async () => {
|
||||
const drone = view({ defId: 'enemy_1112_emppnt' });
|
||||
const plain = view({ defId: 'enemy_1005_yokai' });
|
||||
await tick(); await tick();
|
||||
for (const v of [drone, plain]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
|
||||
const aspect = (v) => Math.abs(v.actor.spine.scale.y) / Math.abs(v.actor.spine.scale.x);
|
||||
assert.ok(Math.abs(aspect(drone) - 1.263) < 1e-9, `先兆者 sy/sx ${aspect(drone)}`);
|
||||
assert.ok(Math.abs(aspect(plain) - 1) < 1e-9, 'a uniform model stays 1:1');
|
||||
assert.equal(U.enemyModelScaleY(E.enemy_1112_emppnt), 1.263);
|
||||
assert.equal(U.enemyModelScaleY(E.enemy_1005_yokai), 1);
|
||||
assert.equal(U.enemyModelScaleY(null), 1);
|
||||
assert.equal(U.enemyModelScaleY({ modelScaleY: 'x' }), 1);
|
||||
assert.equal(U.enemyModelScaleY({ modelScaleY: 99 }), 1, 'absurd values fall back to uniform');
|
||||
// the HP bar rides the stretched model: 390 units × 1/320 × 0.7037 × 1.263 × 0.92
|
||||
assert.ok(Math.abs(drone._headTiles - 390 * UNIT.modelScale * 0.7037 * 1.263 * 0.92) < 1e-9, `bar ${drone._headTiles}`);
|
||||
assert.ok(drone._headTiles > plain._headTiles);
|
||||
});
|
||||
|
||||
test('UnitView: a mirrored prefab is drawn flipped against an unmirrored model facing the same way', async () => {
|
||||
const mirrored = view({ defId: 'enemy_1196_msfyin', motion: 'WALK' });
|
||||
const normal = view({ defId: 'enemy_1007_slime', motion: 'WALK' });
|
||||
await tick(); await tick();
|
||||
for (const v of [mirrored, normal]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
|
||||
assert.equal(mirrored.mirrorX, true);
|
||||
assert.equal(normal.mirrorX, false);
|
||||
assert.equal(mirrored.visFacing, normal.visFacing, 'both face the same way');
|
||||
assert.equal(Math.sign(mirrored.actor.spine.scale.x), -Math.sign(normal.actor.spine.scale.x), 'the mirrored model flips');
|
||||
assert.ok(Math.abs(mirrored.actor.spine.scale.y) > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#13 the self-feared “萨科塔之翼” turns left and right on screen', () => {
|
||||
test('real sim → snapshot buffer → UnitView: facing flips while it flutters, none while it flies its route', () => {
|
||||
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 120, units: [] });
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
|
||||
|
||||
let fake, UnitView, T;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
@@ -272,3 +278,70 @@ describe('enemy preview pen figures (lod idle)', () => {
|
||||
assert.ok(steps() <= 12, `idle loop stepped ≈ every 3rd frame (${steps()} of 30)`);
|
||||
});
|
||||
});
|
||||
|
||||
// Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official
|
||||
// screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the
|
||||
// official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor
|
||||
// (docs/research/12-flying-visuals-official.md) — measured in the client's character space, whose unit is the standard
|
||||
// battle-prefab scale 0.27, so the tile-space lift is 0.35 / 0.27 ≈ 1.3 tiles (the screenshot measures 1.2–1.4). The
|
||||
// client applies it to the model's root transform and nothing per model (its battle prefabs carry no flyer-specific
|
||||
// vertical offset), so a model whose art hangs below its origin keeps that hang and flies with it — 妖怪 at ≈ 0.9 tiles
|
||||
// of rotor clearance. The previous flat 0.32 left every flyer ~1 tile too low (the two 妖怪 drones even had their art
|
||||
// under the tile).
|
||||
describe('flying units hover FLY_HOVER above the ground, whatever their model', () => {
|
||||
const boundsOf = (key) => { const sp = ASSETS.enemies[key].spine; return (sp.front || sp).bounds; };
|
||||
const MODEL_K = { enemy_1005_yokai: 0.7407, enemy_1005_yokai_2: 0.8148, enemy_1040_bombd: 0.7407, enemy_1042_frostd: 0.6667 };
|
||||
/** Independent algorithm: tiles the art bottom hangs below the unit's ground point. */
|
||||
const sinkOf = (key) => (-boundsOf(key).y / 320) * MODEL_K[key];
|
||||
|
||||
test('FLY_HOVER is the client constant 0.35 in character space, i.e. 0.35 / 0.27 tiles', async () => {
|
||||
const { FLY_HOVER } = await import('../../public/js/render/units.js');
|
||||
const STANDARD_PREFAB_SCALE = 0.27; // enemies.json modelScale is a multiple of it (units.js enemyModelScale)
|
||||
const want = 0.35 / STANDARD_PREFAB_SCALE;
|
||||
assert.ok(Math.abs(FLY_HOVER - want) < 0.02, `FLY_HOVER ${FLY_HOVER} ≈ 0.35 / ${STANDARD_PREFAB_SCALE} = ${want.toFixed(3)} 格`);
|
||||
});
|
||||
|
||||
test('every flyer of this mode gets the same lift, whatever its model hangs below its origin', async () => {
|
||||
const { FLY_HOVER } = await import('../../public/js/render/units.js');
|
||||
for (const key of Object.keys(MODEL_K)) {
|
||||
const sink = sinkOf(key);
|
||||
assert.ok(sink > 0, `${key}: the model does hang ${sink.toFixed(3)} tiles below its pivot`);
|
||||
// the lift is model-independent, so a flyer's visible clearance is FLY_HOVER − sink, and it differs per model
|
||||
assert.ok(FLY_HOVER - sink > 0.8, `${key}: 净高度 ${(FLY_HOVER - sink).toFixed(3)} 格(修复前 0.32 − sink 为负 → 贴地)`);
|
||||
}
|
||||
// with the old flat 0.32 the two 妖怪 drones had a negative clearance = art under the tile, and 寒霜 floated 0.25
|
||||
assert.ok(0.32 - sinkOf('enemy_1005_yokai') < 0, 'before: 妖怪 −0.06 tiles');
|
||||
assert.ok(0.32 - sinkOf('enemy_1042_frostd') > 0.2, 'before: 寒霜 floated 0.25 tiles (inconsistent)');
|
||||
// a model whose art starts above its pivot (帝国炮火先兆者) gets the plain FLY_HOVER
|
||||
assert.equal(boundsOf('enemy_1112_emppnt').y > 0, true, '帝国炮火先兆者 art bottom is above the origin');
|
||||
assert.ok(FLY_HOVER > 1.2, 'the sub-tile 0.35 left every flyer about one tile low');
|
||||
});
|
||||
|
||||
test('a flying UnitView lifts by FLY_HOVER — its body and HP bar ride it, its shadow stays on the ground; a ground view keeps its feet on the tile', async () => {
|
||||
const { FLY_HOVER } = await import('../../public/js/render/units.js');
|
||||
const bounds = boundsOf('enemy_1005_yokai');
|
||||
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle' }, animations: { Idle: 1 }, bounds };
|
||||
const assets = {
|
||||
picture: () => null, image: async () => null, spineEntry: () => entry,
|
||||
spine: { acquire: async () => ({ animations: [{ name: 'Idle' }] }), release() {} },
|
||||
};
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam: cam, lookupDef: () => ({ modelScale: MODEL_K.enemy_1005_yokai }) });
|
||||
const fly = new UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', defId: 'enemy_1005_yokai', x: 5, y: 12, maxHp: 100, motion: 'FLY' }, {});
|
||||
await tick(); await tick();
|
||||
for (let i = 0; i < 180; i++) fly.update(1 / 60, cam(), i / 60);
|
||||
assert.ok(Math.abs(fly.hover - FLY_HOVER) < 1e-3, `hover ${fly.hover.toFixed(3)} ≈ FLY_HOVER ${FLY_HOVER}`);
|
||||
const ground = new UnitView(ctx, { id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_1005_yokai', x: 5, y: 12, maxHp: 100 }, {});
|
||||
await tick(); await tick();
|
||||
for (let i = 0; i < 60; i++) ground.update(1 / 60, cam(), i / 60);
|
||||
assert.equal(ground.hover, 0, 'a ground unit is not lifted');
|
||||
// the same tile: the flyer's body (and the bar above it) is FLY_HOVER higher on screen, the shadows coincide
|
||||
const c = cam();
|
||||
const lift = c.project(5, 12, 0).y - c.project(5, 12, fly.hover).y;
|
||||
assert.ok(lift > 0);
|
||||
assert.ok(Math.abs((ground.screen.y - fly.screen.y) - lift) < 1e-6, `body lifted by the projected FLY_HOVER (${ground.screen.y - fly.screen.y} vs ${lift})`);
|
||||
// the bar sits the head height above the body (the projected scale at the body's height, so not exactly `lift`)
|
||||
assert.ok(fly.screen.top < fly.screen.y && Math.abs((fly.screen.y - fly.screen.top) - (ground.screen.y - ground.screen.top)) < 0.1 * (ground.screen.y - ground.screen.top), 'the HP bar keeps its head height over the body');
|
||||
assert.ok(ground.screen.top - fly.screen.top > 0.9 * lift, `the HP bar rides the body (${ground.screen.top - fly.screen.top} vs ${lift})`);
|
||||
assert.ok(Math.abs(fly.shadow.position.y - ground.shadow.position.y) < 1e-9, 'the shadow stays on the ground');
|
||||
});
|
||||
});
|
||||
@@ -2293,6 +2293,30 @@ const MODEL_SCALES = new Map([
|
||||
const MODEL_SCALE_BY_PREFAB = new Map();
|
||||
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
|
||||
|
||||
/**
|
||||
* Prefabs the official stretches **vertically** → enemies.json `modelScaleY` (the Graphic node's Y scale ÷ its X scale;
|
||||
* 1, absent, = uniform). MODEL_SCALES above only carries the horizontal product, so a model whose `Graphic` has
|
||||
* (sx, sy, sz) with sy ≠ sx is drawn too short by a uniform scale. Swept with tools/local-extract/enemy_model_offsets.py
|
||||
* over the local client (2026-10-05, PR #211 by @xcdoge): of 242 readable enemy prefabs only two are non-uniform, both
|
||||
* 1.263 — the pair 帝国炮火先兆者 / 帝国炮火中枢先兆者, Graphic scale (0.19, 0.24, 0.24) — so the official draws them 26 %
|
||||
* taller than their width-implied scale. (The same sweep found no per-model *position* correction for flyers: their
|
||||
* Graphic node sits at local (0,0,0), or at the (0,−0.2,−0.06) their ground-unit prefab family shares — unrelated to
|
||||
* `bounds.y`; render/units.js FLY_HOVER.) Taken with the mirror below by the owner's decision of 2026-10-06.
|
||||
*/
|
||||
const MODEL_STRETCH_Y = new Map([
|
||||
[1.263, ['1112_emppnt', '1112_emppnt_2']],
|
||||
]);
|
||||
const MODEL_STRETCH_Y_BY_PREFAB = new Map();
|
||||
for (const [v, list] of MODEL_STRETCH_Y) for (const k of list) MODEL_STRETCH_Y_BY_PREFAB.set(`enemy_${k}`, v);
|
||||
|
||||
/**
|
||||
* Prefabs whose `Graphic` X scale is **negative** → enemies.json `mirrorX: true`: the official mirrors the authored
|
||||
* model horizontally, while this pipeline takes `abs(sx)` (enemy_scales.py / MODEL_SCALES), so the renderer flips this
|
||||
* model's facing on top of the normal direction flip (render/units.js). From the same sweep: `enemy_1196_msfyin`
|
||||
* (木制瑞印, Graphic scale (−0.4, 0.4, 0.24)) is the only such enemy of the 242.
|
||||
*/
|
||||
const MIRRORED_PREFABS = new Set(['enemy_1196_msfyin']);
|
||||
|
||||
/**
|
||||
* An enemy's attack clip → enemies.json `attackAnim` { clip, dur, hit } (GitHub #58: an unblocked ranged enemy stands for
|
||||
* its attack clip, server/sim/ai.js attackStand): the clip the client plays for its attacks (the asset manifest's
|
||||
@@ -2405,6 +2429,7 @@ function buildEnemies(ctx) {
|
||||
const descRaw = mv(data.description);
|
||||
const hitArea = HIT_AREAS[mv(data.prefabKey) || key] || null;
|
||||
const modelScale = MODEL_SCALE_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
|
||||
const modelScaleY = MODEL_STRETCH_Y_BY_PREFAB.get(mv(data.prefabKey) || key) ?? null;
|
||||
const attackAnim = enemyAttackAnim(ctx.manifest, mv(data.prefabKey) || key);
|
||||
out[key] = {
|
||||
key, name, level: wantLevel, rank: mv(data.levelType, 'NORMAL'), handbookIndex: hb?.enemyIndex || null,
|
||||
@@ -2428,6 +2453,8 @@ function buildEnemies(ctx) {
|
||||
...(hitArea ? { hitArea: { ...hitArea } } : {}),
|
||||
...(STATIC_BODIES.has(key) ? { staticBody: true } : {}),
|
||||
...(modelScale != null && modelScale !== 1 ? { modelScale } : {}),
|
||||
...(modelScaleY != null && modelScaleY !== 1 ? { modelScaleY } : {}),
|
||||
...(MIRRORED_PREFABS.has(mv(data.prefabKey) || key) ? { mirrorX: true } : {}),
|
||||
...(attackAnim ? { attackAnim } : {}),
|
||||
...(attacksOnTheMove(abilities) ? { attackMoves: true } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Official per-model model placement, read from a locally installed Arknights client (optional, host-side).
|
||||
|
||||
Answers "does the client move a model relative to its unit position, and does it scale it?" — the two questions the
|
||||
remake's renderer needs (public/js/render/units.js FLY_HOVER / enemyModelScale).
|
||||
|
||||
It reads the battle prefabs (`dyn/battle/prefabs/enemies/<prefab>.prefab`, bundles `battle/enm_pfb_*.ab`) and reports,
|
||||
per enemy:
|
||||
|
||||
* `Graphic` — the node that carries the Spine model — its **local position and scale relative to the prefab root**.
|
||||
The *root's* own local position is an editor leftover (妖怪 (0,0,0), 帝国炮火先兆者 (4.23,2.11,−1.2), 源石虫 (3,3,0),
|
||||
掠海漂移体 (2.21,5.23,0)) and is overwritten with the unit's world position at instantiation, so it means nothing;
|
||||
only the `Graphic` node is a real placement.
|
||||
* the comparison `sy / sx`: ≠1 means the client stretches the model vertically (measured for 帝国炮火先兆者 /
|
||||
帝国炮火中枢先兆者: Graphic scale (0.19, 0.24, 0.24) — the model is drawn 1.263× taller than its uniform scale, while
|
||||
`enemies.json modelScale` only carries the horizontal 0.19/0.27).
|
||||
|
||||
Findings for this mode's 28 flying units (2026-10-05): the `Graphic` vertical offset is **0** for all of them — 10 at
|
||||
local (0,0,0) and 17 at the same (0,−0.2,−0.06) that ground units such as 源石虫 share (a prefab-family convention, not a
|
||||
per-model correction) — i.e. the client applies **no per-model vertical correction**, which is why
|
||||
`units.js FLY_HOVER` is a flat constant and why a model whose art hangs below its pivot keeps that hang.
|
||||
|
||||
Usage:
|
||||
python tools/local-extract/enemy_model_offsets.py [--game <AB root>] [--keys k1,k2,...] [--only-fly]
|
||||
Needs UnityPy + lz4 (tools/local-extract/requirements.txt).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from extract import CANDIDATES, ROOT # noqa: E402
|
||||
|
||||
import aklz4 # noqa: F401
|
||||
import UnityPy
|
||||
|
||||
|
||||
def graphic_of(root_god, by_id):
|
||||
"""The prefab root's `Graphic` child → ((x, y, z), (sx, sy, sz)), or (None, None) when absent."""
|
||||
for c in root_god['m_Component']:
|
||||
r = by_id.get(c['component']['m_PathID'])
|
||||
if r is None or r.type.name != 'Transform':
|
||||
continue
|
||||
tr = r.read_typetree()
|
||||
for ch in tr['m_Children']:
|
||||
cr = by_id.get(ch['m_PathID'])
|
||||
if cr is None:
|
||||
continue
|
||||
ctr = cr.read_typetree()
|
||||
g = by_id.get(ctr['m_GameObject']['m_PathID'])
|
||||
if g is None:
|
||||
continue
|
||||
if g.read_typetree().get('m_Name') == 'Graphic':
|
||||
p = ctr['m_LocalPosition']
|
||||
s = ctr['m_LocalScale']
|
||||
return (p['x'], p['y'], p['z']), (s['x'], s['y'], s['z'])
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
|
||||
ap.add_argument('--game', help='AB root of the local client (default: the known install locations)')
|
||||
ap.add_argument('--keys', help='comma-separated enemies.json keys (default: every enemy)')
|
||||
ap.add_argument('--only-fly', action='store_true', help='just the units whose stats.motion is FLY')
|
||||
ap.add_argument('--json', action='store_true', help='print the raw {prefab: {pos, scale}} JSON instead of a table')
|
||||
args = ap.parse_args()
|
||||
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
|
||||
if not ab_root or not (ab_root / 'battle').exists():
|
||||
sys.exit('enemy_model_offsets: no local client found (pass --game <AB root>)')
|
||||
|
||||
enemies = json.loads((ROOT / 'data' / 'enemies.json').read_text('utf8'))
|
||||
if args.keys:
|
||||
keys = [k.strip() for k in args.keys.split(',') if k.strip()]
|
||||
else:
|
||||
keys = [k for k, v in enemies.items() if not args.only_fly or v.get('stats', {}).get('motion') == 'FLY']
|
||||
wanted = {enemies[k].get('spine') or k: k for k in keys if k in enemies}
|
||||
|
||||
rows = {}
|
||||
for f in sorted((ab_root / 'battle').glob('enm_pfb*.ab')):
|
||||
env = UnityPy.load(str(f))
|
||||
by_id = {o.path_id: o for o in env.objects}
|
||||
for name, obj in env.container.items():
|
||||
m = re.search(r'/enemies/(.+)\.prefab$', name)
|
||||
if m and m.group(1) in wanted:
|
||||
rows[m.group(1)] = graphic_of(obj.read_typetree(), by_id)
|
||||
|
||||
if args.json:
|
||||
out = {}
|
||||
for prefab, key in wanted.items():
|
||||
e = enemies[key]
|
||||
if prefab not in rows or rows[prefab][0] is None:
|
||||
out[key] = None
|
||||
continue
|
||||
p, s = rows[prefab]
|
||||
out[key] = {'prefab': prefab, 'name': e.get('name'), 'motion': e.get('stats', {}).get('motion'),
|
||||
'pos': list(p), 'scale': list(s), 'modelScale': e.get('modelScale')}
|
||||
print(json.dumps(out, indent=1, sort_keys=True, ensure_ascii=False))
|
||||
return
|
||||
|
||||
print(f"{'key':<24}{'name':<18}{'Graphic pos (x,y,z)':<28}{'scale (sx,sy,sz)':<28}{'sy/sx':<8}{'modelScale'}")
|
||||
for prefab, key in wanted.items():
|
||||
e = enemies[key]
|
||||
ms = e.get('modelScale')
|
||||
if prefab not in rows or rows[prefab][0] is None:
|
||||
print(f'{key:<24}{str(e.get("name"))[:16]:<18}— no Graphic node —')
|
||||
continue
|
||||
p, s = rows[prefab]
|
||||
ratio = (s[1] / s[0]) if s[0] else float('nan')
|
||||
print(f'{key:<24}{str(e.get("name"))[:16]:<18}'
|
||||
f'({p[0]:.3f}, {p[1]:.3f}, {p[2]:.3f})'.ljust(28)
|
||||
+ f'({s[0]:.4f}, {s[1]:.4f}, {s[2]:.4f})'.ljust(28)
|
||||
+ f'{ratio:<8.3f}{ms if ms is not None else 1}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user