From 14df10a5a315b51e993d1197cd082e72a3ece848 Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 7 Oct 2026 17:49:51 +0800 Subject: [PATCH] =?UTF-8?q?feedback6=20R21F:=20an=20enemy=20flyer=20hovers?= =?UTF-8?q?=20FLY=5FHOVER=20above=20the=20road=20whatever=20tile=20it=20cr?= =?UTF-8?q?osses=20(GitHub=20#277:=20one=20high-ground=20/=20forbidden=20b?= =?UTF-8?q?lock=20on=20its=20way=20lifted=20it=200.42=20/=200.3=20tiles=20?= =?UTF-8?q?and=20dropped=20it=20again=20like=20a=20step)=20=E2=80=94=20Uni?= =?UTF-8?q?tView.sync=20keeps=20every=20enemy=20at=20z=200,=20operators=20?= =?UTF-8?q?and=20summons=20keep=20their=20tile=20top,=20the=20flyer's=20sh?= =?UTF-8?q?adow=20lies=20on=20the=20tile=20under=20it=20(shadowZ)=20on=20t?= =?UTF-8?q?he=202D=20and=20the=203D=20board;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ASSETS.md | 2 +- docs/design/client.md | 2 +- docs/research/12-flying-visuals-official.md | 6 +++ public/js/render/units.js | 36 ++++++++++++---- test/render/unitview.test.js | 46 +++++++++++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/docs/ASSETS.md b/docs/ASSETS.md index e2e6c220..a03266fa 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -299,7 +299,7 @@ Other renderer rules from research 07 §5.4–5.5: - **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up — while it stands: a dead or knocked-out operator falls and lies with the Front model unless its Back skeleton has a Die clip of its own (131 of the 135 have none; DESIGN §22.1, GitHub issue #25). - **Attack speed:** set the attack `timeScale` to `duration / attackInterval`. - **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them. Two prefab quirks on top (PR #211 by @xcdoge; the owner's decision of 2026-10-06; docs/research/12 §3.1): the two 帝国炮火先兆者 are stretched vertically (`modelScaleY` 1.263: their Graphic scale is (0.19, 0.24, 0.24)) and 木制瑞印 is mirrored (`mirrorX`: a negative Graphic X scale) — `tools/local-extract/enemy_model_offsets.py` reads them, `tools/build-data.mjs MODEL_STRETCH_Y` / `MIRRORED_PREFABS` keep them. -- **Flying units** hover `FLY_HOVER` = 1.3 tiles above the ground they cross (render/units.js; the client's single fly offset 0.35 in its character space, whose unit is the standard prefab scale 0.27 — docs/research/12): the body, its HP bar, damage numbers and hits ride the lift, the shadow stays on the ground tile, on the 2D and the 3D board alike (one camera drives both). +- **Flying units** hover `FLY_HOVER` = 1.3 tiles up (render/units.js; the client's single fly offset 0.35 in its character space, whose unit is the standard prefab scale 0.27 — docs/research/12) — an enemy flyer above the road whatever tile it crosses (a high-ground or forbidden block under it is no step, GitHub #277), an operator or summon above its tile: the body, its HP bar, damage numbers and hits ride the lift, the shadow stays on the ground tile (the block top under an enemy flyer crossing one), on the 2D and the 3D board alike (one camera drives both). - **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases: - `_2` variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game. - an enemy whose own model no dump carries: 灼热源石虫 / 炽焰源石虫 (`enemy_1305_mhslim` / `_2`) use the plain 源石虫 on diff --git a/docs/design/client.md b/docs/design/client.md index 9619259a..5a3db6c4 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -29,7 +29,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/docs/research/12-flying-visuals-official.md b/docs/research/12-flying-visuals-official.md index 0ea06338..28611480 100644 --- a/docs/research/12-flying-visuals-official.md +++ b/docs/research/12-flying-visuals-official.md @@ -331,6 +331,12 @@ explains every other per-model difference (§3.1): **the code never repositions 3 tests: `FLY_HOVER ≈ 0.35 / 0.27`; every flyer of this mode keeps a positive, consistent net height and the two 妖怪 drones *were* under the tile at the old 0.32 (with 帝国炮火先兆者's `bounds.y > 0` pinned as the "art above origin" case); and a view-level wiring test (a flying view's `hover === FLY_HOVER`, a ground view's `hover === 0`). +- 0.2.1 (GitHub #277 by @FrogThai, 飞机经过一格方块时会跟走楼梯一样): the lift of an **enemy** flyer starts from the road. + The view had put a flyer on the tile top under it (`groundZ`) before adding `FLY_HOVER`, so one high-ground / + forbidden block on its way lifted it 0.42 / 0.3 tiles and dropped it again; the client's offset is one constant over + the route, so `UnitView.sync` keeps every enemy at z 0 (ground enemies already were), operators and summons keep their + tile top. The shadow lies on the tile under the flyer (`shadowZ`; [ASSUMED] — no official reference for a shadow over + a block). Test: `describe('an enemy flyer crossing a raised tile keeps its height (GitHub #277)')`. - The model quirks (§3.1): `tools/build-data.mjs MODEL_STRETCH_Y` / `MIRRORED_PREFABS` → enemies.json `modelScaleY` / `mirrorX` (0.2.0 regenerated its own enemies.json offline; only those three keys changed), `render/units.js enemyModelScaleY` and the mirror flip; `tools/local-extract/enemy_model_offsets.py` (optional, host-side, UnityPy) reads diff --git a/public/js/render/units.js b/public/js/render/units.js index 0ab4e3d8..48b3f2d6 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -137,8 +137,9 @@ export const SPINE_STUCK_MS = 5000; /** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */ const RAISED_Z = 0.12; /** - * Flying units hover this many tiles above the ground they cross (PR #211 by @xcdoge; the owner's decision of - * 2026-10-06; docs/research/12-flying-visuals-official.md). + * Flying units hover this many tiles up (PR #211 by @xcdoge; the owner's decision of 2026-10-06; + * docs/research/12-flying-visuals-official.md): an enemy flyer above the road (z 0) whatever tile it crosses — a raised + * block under it is no step (GitHub #277) —, an operator or summon above its tile. * * The official client's fly offset is a **single constant, `Vector3(0, 0.35f, 0)`**: `Torappu.Battle.CharacterAnimator`'s * constructor stores it in the instance field at +0x114 (`GameAssembly.dll` 0x180600555 reads the constant at 0x186a78a50 = @@ -373,9 +374,13 @@ export class UnitView { this.golden = !!info.golden; this.tier = clamp(Number(info.tier) || 1, 1, 6); this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0; - this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit) + this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies) + /** @type {number|null} */ + this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z + /** @type {number|null} */ + this.shadowZTarget = null; this.flying = info.motion === 'FLY'; - this.hover = 0; // flying: body height above the ground under it + this.hover = 0; // flying: body height above the road / the ground under it this.dir = this.isEnemy ? null : unitDir(info); // whether the direction is known (UnitInfo / piece `dir`), not just the legacy ±1: battle and scouting views show // the ground wedge only then (a derived RIGHT would mislabel an UP / DOWN operator) @@ -704,10 +709,17 @@ export class UnitView { this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0; this.x = s.x; this.y = s.y; this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY'; - // ground enemies only ever walk low tiles (a rounding step onto a block edge must not pop them up) - const gz = this.isEnemy && !this.flying ? 0 : groundZ(this.ctx, s.x, s.y); + // enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not + // pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one + // constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one + // high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it. + const floor = groundZ(this.ctx, s.x, s.y); + const gz = this.isEnemy ? 0 : floor; if (this.zTarget == null) this.z = gz; this.zTarget = gz; + this.shadowZTarget = this.isEnemy && this.flying ? floor : null; + if (this.shadowZTarget == null) this.shadowZ = null; + else if (this.shadowZ == null) this.shadowZ = this.z; if (s.maxHp > 0) this.maxHp = s.maxHp; const hp = clamp(s.hp, 0, this.maxHp); if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25; @@ -941,6 +953,10 @@ export class UnitView { const d = this.zTarget - this.z; this.z = Math.abs(d) < 1e-3 ? this.zTarget : this.z + d * Math.min(1, dt * 12); } + if (this.shadowZTarget != null && this.shadowZ !== this.shadowZTarget) { + const d = this.shadowZTarget - this.shadowZ; + this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12); + } const hoverTo = this.flying && this.alive ? FLY_HOVER : 0; if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6); const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen); @@ -977,9 +993,11 @@ export class UnitView { if (this._cull(bx, by, s, dt)) return; const flip = (this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing) * (this.mirrorX ? -1 : 1); - // shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything) - placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z); - const sh = cam.project(this.x, this.y, this.z, SH_P); + // shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything); an enemy + // flyer's lies on the tile under it while its body hovers from the road (shadowZ, GitHub #277) + const shz = this.shadowZ ?? this.z; + placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, shz); + const sh = cam.project(this.x, this.y, shz, SH_P); this.shadow.position.set(sh.x, sh.y); const shw = s * (this.isBoss ? 1.6 : 0.95) / this.shadow.texture.width; this.shadow.scale.set(shw, shw * (this.shadow.texture === shadowTexture() ? 1 : 1.05)); diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 7f4aed4e..f0bf5df5 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -345,3 +345,49 @@ describe('flying units hover FLY_HOVER above the ground, whatever their model', assert.ok(Math.abs(fly.shadow.position.y - ground.shadow.position.y) < 1e-9, 'the shadow stays on the ground'); }); }); + +// GitHub #277 (@FrogThai): 飞机经过一格方块时会跟走楼梯一样,有高低差 — a flyer crossing one raised tile (high ground, +// a forbidden block) rose onto the block and dropped back like a step, because the view added the tile's height under +// it before its FLY_HOVER. The official lift is one constant over the route (docs/research/12: Vector3(0, 0.35, 0) added +// while flying), so an enemy flyer hovers from the road (z 0) whatever tile it crosses; its shadow lies on the tile top +// under it. Ground enemies keep to the road as before, and an operator on high ground keeps standing on the block. +describe('an enemy flyer crossing a raised tile keeps its height (GitHub #277)', () => { + const RAISED = { row: 12, col: 6, h: 0.42 }; // one high-ground block ('h', TILE_H.wall) on the flyer's row + const heightAt = (r, c) => (r === RAISED.row && c === RAISED.col ? RAISED.h : 0); + const sample = (x, flags) => ({ x, y: 12, hp: 100, maxHp: 100, sp: 0, spMax: 0, flags, anim: 1, vx: 0.5 }); + + test('the body stays FLY_HOVER above the road over the block; the shadow lies on the block top', async () => { + const { FLY_HOVER } = await import('../../public/js/render/units.js'); + const ctx = fakeViewCtx(fake.P, { assets: store(), cam, heightAt }); + const fly = new UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', defId: 'enemy_1005_yokai', x: 4, y: 12, maxHp: 100, motion: 'FLY' }, {}); + await tick(); await tick(); + let t = 0; + const step = (x) => { fly.sync(sample(x, 512), t); fly.update(1 / 60, cam(), t); t += 1 / 60; }; + for (let i = 0; i < 180; i++) step(4); // settle the lift on the road + const heights = []; + for (let i = 0; i <= 80; i++) { step(4 + i * 0.05); heights.push(fly.z + fly.hover); } // x 4 → 8 across col 6 + const rise = Math.max(...heights) - Math.min(...heights); + assert.ok(rise < 1e-6, `the body height never changes over the block (rose ${rise.toFixed(3)} tiles; before: +${RAISED.h})`); + assert.ok(Math.abs(heights[0] - FLY_HOVER) < 1e-3, `FLY_HOVER above the road (${heights[0].toFixed(3)})`); + for (let i = 0; i < 60; i++) step(6); // hold over the block + assert.equal(fly.z, 0, 'the flyer hovers from the road plane'); + const c = cam(); + assert.ok(Math.abs(fly.screen.y - c.project(6, 12, FLY_HOVER).y) < 1e-6, 'drawn FLY_HOVER above the road, not above the block'); + assert.ok(Math.abs(fly.shadow.position.y - c.project(6, 12, RAISED.h).y) < 0.05, 'its shadow lies on the block top under it'); + for (let i = 0; i < 60; i++) step(8); // back over the road + assert.ok(Math.abs(fly.shadow.position.y - c.project(8, 12, 0).y) < 0.05, 'and on the road again past it'); + }); + + test('ground enemies stay on the road and an operator on the block stands on its top', async () => { + const ctx = fakeViewCtx(fake.P, { assets: store(), cam, heightAt }); + const walker = new UnitView(ctx, { id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', x: 6, y: 12, maxHp: 100 }, {}); + const op = new UnitView(ctx, { id: 3, side: 'ally', kind: 'chess', defId: 'char_x', tier: 1, x: 6, y: 12, maxHp: 100 }, {}); + await tick(); await tick(); + for (let i = 0; i < 60; i++) { + walker.sync(sample(6, 0), i / 60); walker.update(1 / 60, cam(), i / 60); + op.sync({ ...sample(6, 0), anim: 0, vx: 0 }, i / 60); op.update(1 / 60, cam(), i / 60); + } + assert.equal(walker.z, 0, 'a ground enemy is never popped onto a block'); + assert.ok(Math.abs(op.z - RAISED.h) < 1e-6, `the operator stands on the high ground (${op.z})`); + }); +});