feedback7 R22-HUD: the cached-atlas refreshes of a crowded battle take their turns from this frame's update order, not random phases — each frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units (step 1 of GitHub #327, PR #334 by @Cloudnyco); golden: unchanged
Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no dependency). UnitView._updateImpostor refreshed a unit when (frame + phase) % interval === 0 with a random phase per unit, so the refreshes of a frame were binomial: reproduced on the parent with 7 pen figures at interval 3 — 5 1 1 5 1 1 … and 2 4 1 2 4 1 … refreshes per frame instead of 2 / 3. Now render/app.js hands every impostor unit the next slot of the frame (ctx.impostorSlot, a counter reset in frameBody), the unit refreshes when (frame + slot) % interval === 0, and 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 (imp.last); a context without slots keeps the random phase. Each unit's rate and the total work are unchanged; no sim or pixel-as-state change. Step 2 of #327 (a time budget by priority) is not part of this and still needs a decision. - Tests: test/render/unitview.test.js 'impostor refresh slots' (3: slots in update order — fails on the parent, the starvation cap, the no-slots context); unit view / render suites unchanged. - Docs: design/client.md. Golden: unchanged (client only).
This commit is contained in:
1 parent
3484178e2e
commit
bc11c0d61a
4 files changed
+86
-3
No files matched your search
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user