diff --git a/docs/design/client.md b/docs/design/client.md index 63765d80..783c9ea4 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -32,7 +32,7 @@ view.resize() ; view.destroy() `createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. -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; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), 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; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), 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 (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). 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/public/js/render/app.js b/public/js/render/app.js index 0753abe3..51474794 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -316,6 +316,7 @@ export async function createFieldView(host, options = {}) { renderer: app.renderer, frameNo: () => frameNo, impostorInterval: () => impInterval, + impostorSlot: () => impSlot++, clipAllowed: () => clipAllowed, viewport: () => vp, loadLevel: () => loadLevel, @@ -1565,6 +1566,10 @@ export async function createFieldView(host, options = {}) { let cpuMs = 0; let frameNo = 0; let impInterval = 0; + // Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when + // (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the + // busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle. + let impSlot = 0; let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling let culledCount = 0; // Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to @@ -1605,6 +1610,7 @@ export async function createFieldView(host, options = {}) { } function frameBody(now) { frameNo++; + impSlot = 0; if (frameNo % 30 === 1) { impInterval = pickImpostorInterval(); clipAllowed = pickClipping(); culledCount = 0; diff --git a/public/js/render/units.js b/public/js/render/units.js index b385fe42..9e9b8819 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -1457,16 +1457,22 @@ export class UnitView { if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine); if (atlas) atlas.park(this.actor.spine); this.body.addChild(sprite); - this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true }; + this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 }; } const imp = this.imp; imp.acc += animDt; const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0; - const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12; + // the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval; + // a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it + // culled on and off in step) is still refreshed after 2 intervals at the latest. + const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase; + const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval + || Math.abs(sc - imp.sc) > imp.sc * 0.12; if (due) { this.actor.update(imp.acc); imp.acc = 0; imp.dirty = false; + imp.last = frame; this._renderImpostor(sc, atlas); } const k = imp.sc > 0 ? sc / imp.sc : 1; diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 26e426b9..6fd882ea 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -279,6 +279,77 @@ describe('enemy preview pen figures (lod idle)', () => { }); }); +// The impostor refreshes of a frame are spread evenly over the interval (app.js impostorSlot: each frame the impostor +// units take slots 0, 1, 2 … in update order): a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. With a random phase per +// unit, the busiest frame did 20–66% more refreshes than that (16–48 units, intervals 2–6) — the frame-time peaks of a +// crowded battle on a slow device. +describe('impostor refresh slots', () => { + /** n pen figures (lod idle: interval 3) in one context; `slotOf(frame, i)` gives view i its slot, or null: no slots. */ + async function crowd(n, slotOf) { + let frame = 0; + let i = 0; + const atlas = { + alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), + free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {}, + }; + const ctx = fakeViewCtx(fake.P, { + assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas, + renderer: { resolution: 1, render() {} }, + ...(slotOf ? { impostorSlot: () => slotOf(frame, i) } : {}), + }); + const views = []; + for (let k = 0; k < n; k++) { + views.push(new UnitView(ctx, { id: `e:${k}`, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' })); + } + await tick(); await tick(); + const refreshed = views.map(() => []); + views.forEach((v, k) => { + assert.ok(v.spineReady, 'spine ready'); + const upd = v.actor.update.bind(v.actor); + v.actor.update = (dt) => { refreshed[k].push(frame); return upd(dt); }; + }); + const step = () => { views.forEach((v, k) => { i = k; v.update(1 / 60, cam(), frame / 60); }); frame++; }; + return { step, refreshed, frames: () => frame }; + } + const perFrame = (refreshed, from, to) => { + const c = new Map(); + for (const list of refreshed) for (const f of list) if (f >= from && f < to) c.set(f, (c.get(f) || 0) + 1); + return Array.from({ length: to - from }, (_, j) => c.get(from + j) || 0); + }; + + test('slots in update order: every frame refreshes ⌊n/3⌋ or ⌈n/3⌉ units, each unit every 3rd frame', async () => { + for (const n of [7, 16, 24]) { + const { step, refreshed } = await crowd(n, (frame, k) => k); + for (let f = 0; f < 31; f++) step(); + // frame 0: every new impostor is drawn once (dirty); from frame 1 on, the interval rules + const counts = perFrame(refreshed, 1, 31); + assert.ok(counts.every((c) => c === Math.floor(n / 3) || c === Math.ceil(n / 3)), `${n} units: ${counts.join(' ')}`); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.equal(after.length, 10, `each unit refreshed 10 times in 30 frames (${after.join(',')})`); + assert.ok(after.every((f, j) => j === 0 || f - after[j - 1] === 3), 'exactly every 3rd frame'); + } + } + }); + + test('a slot that moves with the frame never starves a unit: refreshed at least every 2 intervals', async () => { + // (frame + slot) % 3 is never 0: the turn alone would never come + const { step, refreshed } = await crowd(3, (frame) => 4 - (frame % 3)); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.ok(after.length >= 5, `refreshed ${after.length} times`); + assert.ok([0, ...after].every((f, j, a) => j === 0 || f - a[j - 1] <= 6), `gaps ≤ 6 frames (${after.join(',')})`); + } + }); + + test('without slots (a context that has none) the random phase still refreshes every 3rd frame', async () => { + const { step, refreshed } = await crowd(5, null); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) assert.equal(list.filter((f) => f >= 1).length, 10); + }); +}); + // 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