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
@@ -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