diff --git a/docs/SIM.md b/docs/SIM.md index 592442dd..0f66de58 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -963,7 +963,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex?, form? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's mode recorded by content as `unit.form` — 暴鸰 `'bombed'` after its drop — for views built later) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/public/js/render/app.js b/public/js/render/app.js index 2e145fa4..5ba7ea59 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -1296,6 +1296,8 @@ export async function createFieldView(host, options = {}) { // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + // an enemy's mode already taken (UnitInfo.form: 暴鸰 'bombed'): a field opened later draws it (units.js FORMS) + form: typeof u.form === 'string' ? u.form : undefined, }; infos.set(info.id, info); return info; diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 1954fe4e..b456a5fa 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -1502,6 +1502,7 @@ function kitBombd(ab) { if (!e.alive) return; const atk = e.s.atk; b._ev(['atk', e.id, t.id, 'droneBomb']); + e.form = 'bombed'; // UnitInfo.form: a view built later draws it bomb-less b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: 'bombed' }); b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.droneBomb, visual: 'droneBomb', source: e, hitDead: true, onHit: (c) => land(b, e, atk, c.target, c.x, c.y) }); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 5525bfc3..49d4214d 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -1,8 +1,10 @@ // server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2). // // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } -// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? } -// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). +// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, form? } +// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1); +// form = an enemy's current mode (`unit.form`, the last fx 'phase' kind content recorded — 暴鸰 'bombed'), so a view +// built after the change (a field opened mid-battle) draws it (render/units.js FORMS). // flags bits & anim codes come from shared/constants.js (UF / ANIM). import { UF, ANIM } from '../../shared/constants.js'; @@ -33,6 +35,7 @@ export function unitInfo(u) { motion: u.motion === 'FLY' ? 'FLY' : undefined, boss: u.isBoss ? true : undefined, uid: u.uid ?? undefined, + form: typeof u.form === 'string' ? u.form : undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, // DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared diff --git a/test/render/feedback1d-bombd.test.js b/test/render/feedback1d-bombd.test.js index a00f4cf1..cb937404 100644 --- a/test/render/feedback1d-bombd.test.js +++ b/test/render/feedback1d-bombd.test.js @@ -88,6 +88,13 @@ describe('D4 暴鸰: the drone lets go of its bomb on screen', () => { assert.equal(v.atkInterval, 5, 'a one-off drop leaves the attack rhythm alone'); }); + test('render/app.js keeps UnitInfo.form (a field opened after the drop) and hands the atk kind to the view', () => { + const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); + assert.match(src, /function addInfo\(u\)[\s\S]{0,2000}form: typeof u\.form === 'string' \? u\.form : undefined/); + assert.match(src, /src\.onAttack\?\.\(tgt, now, e\[3\]\)/); + assert.match(src, /v\.windUp\(t - upcomingT, e\[3\]\)/); + }); + test('the drop draws a falling bomb at the sim\'s speed and the mode change draws nothing of its own', () => { assert.ok(PROJ.droneBomb, 'PROJ.droneBomb'); assert.equal(PROJ.droneBomb.speed, PROJECTILE_SPEEDS.droneBomb); diff --git a/test/sim/feedback1d-bombd.test.js b/test/sim/feedback1d-bombd.test.js index 153d2b3d..d6d36a79 100644 --- a/test/sim/feedback1d-bombd.test.js +++ b/test/sim/feedback1d-bombd.test.js @@ -70,6 +70,7 @@ describe('D4 暴鸰: the bomb leaves the drone and lands', () => { assert.ok(Math.abs(relT - castAt - RELEASE) <= TICK + 1e-9, `released ${(relT - castAt).toFixed(3)} s after the trigger (OnAttack ${RELEASE})`); const phase = log.find(([, ev]) => ev[0] === 'fx' && ev[1] === 'phase'); assert.ok(phase && phase[1][4].kind === 'bombed' && Math.abs(phase[0] - relT) < 1e-9, 'the model drops to its bomb-less mode at the release'); + if (drone.alive) assert.equal(b.fieldMeta().units.find((u) => u.id === drone.id).form, 'bombed', 'a field opened later draws it bomb-less'); assert.ok(hitAt != null, 'the bomb lands'); assert.equal(firstHit.id, relEv[2], 'on the operator it was dropped on'); assert.ok(hitAt - relT > TICK, `then flies (${(hitAt - relT).toFixed(3)} s at ${PROJECTILE_SPEEDS.droneBomb} tiles/s)`);