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:
sganggs committed 2026-10-06 21:41:49 +08:00
1 parent 4e58481706
commit 66ae86b969
12 files changed
+703 -17

No files matched your search

+52 -11
View File
@@ -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);