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
+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);
|
||||
|
||||
Reference in new issue
Block a user