Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
// test/render/fxpop.test.js — user playtest #4 item 13 ("屏幕很偶尔的情况下会有个巨大的ui的一部分遮挡一两秒": once in a while a
// huge piece of UI covered the screen for a second or two). The first in-battle bond layer gain of each bond (b.ev
// 'layer' → render/app.js → FxSystem.pop) popped that bond's icon 46 times too big — ~5000 px, the whole screen, for
// the pop's 1.4 s. The icon is PIXI.Texture.from(url): a 1×1 placeholder until its image has loaded (then the real size
// and an 'update' event, Pixi 7.4), and pop() sized the sprite from the placeholder (46 / 1). Reproduced in headless
// Chrome (test/render/flash.browser.test.js); here against a fake PIXI with the same Texture semantics.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem } from '../../public/js/render/fx.js';
class Emitter {
constructor() { this._on = new Map(); }
on(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: false }); return this; }
once(e, f, ctx) { if (!this._on.has(e)) this._on.set(e, []); this._on.get(e).push({ f, ctx, once: true }); return this; }
off(e, f) { const l = this._on.get(e); if (l) this._on.set(e, l.filter((x) => x.f !== f)); return this; }
emit(e, ...a) { const l = this._on.get(e) || []; this._on.set(e, l.filter((x) => !x.once)); for (const x of l) x.f.apply(x.ctx, a); return true; }
}
/** Pixi 7.4 Texture of an image: 1×1 and invalid until its base texture has loaded, then its size + 'update'. */
class Tex extends Emitter {
constructor(w, h, loaded = true) {
super();
this._size = [w, h];
this.baseTexture = { valid: loaded, width: loaded ? w : 1, height: loaded ? h : 1 };
this.orig = { width: loaded ? w : 1, height: loaded ? h : 1 };
this.valid = loaded;
}
get width() { return this.orig.width; }
get height() { return this.orig.height; }
load() {
[this.orig.width, this.orig.height] = this._size;
Object.assign(this.baseTexture, { valid: true, width: this._size[0], height: this._size[1] });
this.valid = true;
this.emit('update', this);
}
}
class Point { constructor(x = 1, y = x) { this.x = x; this.y = y; } set(x, y = x) { this.x = x; this.y = y; } }
class Sprite {
constructor(tex) { this.texture = tex; this.visible = true; this.scale = new Point(1); this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; this.destroyed = false; }
destroy() { this.destroyed = true; }
}
class Container {
constructor() { this.children = []; this.position = new Point(0); this.scale = new Point(1); this.alpha = 1; this.destroyed = false; }
addChild(...c) { this.children.push(...c); return c[0]; }
destroy() { this.destroyed = true; for (const c of this.children) c.destroy?.(); }
}
class BitmapText { constructor(text) { this.text = text; this.anchor = new Point(0); this.position = new Point(0); this.tint = 0xffffff; } destroy() {} }
function fx() {
const f = Object.create(FxSystem.prototype);
Object.assign(f, {
P: { Sprite, Container, BitmapText, BLEND_MODES: { ADD: 1 } },
tex: { glow: new Tex(64, 64) }, pops: [],
ctx: { screenSize: () => ({ width: 1920, height: 1080 }), fieldTop: () => 200, layers: { screen: { addChild() {} } } },
});
return f;
}
/** Screen px of the icon sprite's longer side as drawn (0 while hidden). */
const drawn = (sp) => (sp.visible ? Math.max(sp.texture.width * Math.abs(sp.scale.x), sp.texture.height * Math.abs(sp.scale.y)) : 0);
const iconOf = (f, icon) => f.pops.at(-1).c.children.find((c) => c.texture === icon);
describe('FxSystem.pop: the bond icon is 46 px whenever its image loads (user playtest #4 item 13)', () => {
test('an icon still loading is sized when it has loaded — never from its 1×1 placeholder', () => {
const f = fx();
const icon = new Tex(110, 98, false); // bond/yanShip.png
f.pop(icon, '+2', 0xffffff, 0);
const sp = iconOf(f, icon);
assert.ok(drawn(sp) <= 46.5, `nothing big while loading (${drawn(sp)} px)`);
icon.load();
assert.ok(Math.abs(drawn(sp) - 46) < 0.5, `46 px once loaded (was 110 × 46 = 5060 px): ${drawn(sp)}`);
f._updatePops(0.5);
assert.ok(drawn(sp) * f.pops[0].c.scale.x < 60, 'with the pop\'s own scale-in');
});
test('an icon already loaded: 46 px along its longer side at once', () => {
const f = fx();
const icon = new Tex(102, 100);
f.pop(icon, '+1', 0xffffff, 1);
assert.ok(Math.abs(drawn(iconOf(f, icon)) - 46) < 0.5);
});
test('a pop that ended before its image loaded stays gone', () => {
const f = fx();
const icon = new Tex(110, 98, false);
f.pop(icon, '+3', 0xffffff, 0);
const sp = iconOf(f, icon);
f._updatePops(2);
assert.equal(f.pops.length, 0);
assert.doesNotThrow(() => icon.load());
assert.ok(sp.destroyed);
});
});