Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners

- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+80
View File
@@ -195,6 +195,86 @@ describe('field view teardown (app.js releaseGl)', () => {
});
});
// user playtest #3 item 7: the body as drawn, for render/pick.js (the shared picking rule)
describe('pickShape / bounds / hitTest', () => {
test('the avatar diamond until the Spine model shows, then the chibi at its own measured height', async () => {
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true }, store({ spine: true, imageDelay: 50 }));
v.update(1 / 60, cam(), 0);
assert.equal(v.pickShape().kind, 'diamond', 'no model yet');
await tick(); await tick();
assert.ok(v.spineReady);
// the posed skeleton's bounds: 425.6 units above the feet = 1.33 tiles (UNIT.modelScale 1/320)
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -425.6, width: 120, height: 430 });
v.update(1 / 60, cam(), 1 / 60);
const b = v.pickShape();
assert.equal(b.kind, 'chibi');
assert.ok(Math.abs(b.h - 1.33) < 1e-6, `measured height ${b.h}`);
assert.equal(b.w, 1, 'operators: the measured width profile');
assert.equal(b.x, v.screen.x); assert.equal(b.y, v.screen.y); assert.equal(b.s, v.screen.s);
assert.equal(b.depth, v.root.zIndex);
// measured once: a later pose (a raised weapon) does not move the outline
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -700, width: 120, height: 700 });
v.update(1 / 60, cam(), 2 / 60);
assert.ok(Math.abs(v.pickShape().h - 1.33) < 1e-6);
// the outline tops out at the model; the tier chip (prep) is part of the pick shape, not of bounds()
const r = v.bounds();
assert.ok(Math.abs(r.y - (v.screen.y - 1.33 * v.screen.s)) < 1, 'bounds top = model top');
assert.ok(Math.abs(r.y + r.height - (v.screen.y + 0.08 * v.screen.s)) < 1, 'bounds bottom = just below the feet');
assert.ok(b.hud && b.hud.y1 < v.screen.y - v.screen.s, 'the chip is up at the head (UNIT.headroom)');
assert.ok(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), 'the torso');
assert.ok(!v.hitTest(v.screen.x + 0.6 * v.screen.s, v.screen.y - 0.5 * v.screen.s), 'beside it');
// a new model (Front ⇄ Back swap, _dropActor) is measured again
v._dropActor();
assert.equal(v._bodyH, null);
});
test('implausible skeleton bounds are clamped; enemies scale their width with their size', async () => {
const op = view({ defId: 'char_p2', avatar: 'char_p2' }, { prep: true }, store({ spine: true }));
await tick(); await tick();
op.actor.spine.getLocalBounds = () => ({ x: -500, y: -1200, width: 1000, height: 1200 }); // an effect in the idle pose
op.update(1 / 60, cam(), 0);
assert.equal(op.pickShape().h, 1.55, 'operators ≤ 1.55 tiles');
const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true }));
await tick(); await tick();
foe.actor.spine.getLocalBounds = () => ({ x: -300, y: -640, width: 600, height: 640 });
foe.update(1 / 60, cam(), 0);
const b = foe.pickShape();
assert.ok(Math.abs(b.h - 2) < 1e-6);
assert.ok(Math.abs(b.w - 2 / 1.2) < 1e-6, 'width factor from the height');
assert.equal(b.hud, null, 'an undamaged enemy shows no bar');
});
test('a battle unit\'s bars are its HUD rect; a dead unit is not hit', async () => {
const v = view({ defId: 'char_p3', avatar: 'char_p3' }, {}, store({ spine: true }));
await tick(); await tick();
v.sync({ x: 5, y: 12, hp: 500, maxHp: 1000, sp: 3, spMax: 10, flags: 0, anim: 0, vx: 0 });
v.update(1 / 60, cam(), 0);
const hud = v.pickShape().hud;
assert.ok(hud && hud.x1 > hud.x0 && hud.y1 > hud.y0, 'HP / SP bars and chip');
assert.ok(hud.y1 < v.screen.y - v.screen.s, 'above the head');
v.die();
assert.equal(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), false);
});
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
const { ItemView } = await import('../../public/js/render/units.js');
const ctx = fakeViewCtx(fake.P, { assets: store(), cam });
const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 });
it.setWorld(3, 7, 0.16);
it.update(1 / 60, cam(), 0);
let b = it.pickShape();
assert.equal(b.kind, 'plate');
assert.ok(Math.abs(b.y - (it.screen.y - 0.31 * it.screen.s)) < 1e-6, 'resting: the plate above its anchor');
it.lift = 0.3;
it.update(1 / 60, cam(), 0);
b = it.pickShape();
const g = cam().project(3, 7, 0.16);
assert.ok(Math.abs(b.x - g.x) < 1e-6 && Math.abs(b.y - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)');
assert.equal(it.plate.anchor.y, 0.5);
assert.ok(it.hitTest(g.x, g.y));
});
});
describe('enemy preview pen figures (lod idle)', () => {
/** A UnitView of a pen figure with a Spine model, an impostor atlas (full or not) and a counting renderer. */
async function penFigure(full) {