diff --git a/docs/META.md b/docs/META.md index f12b6a69..292fad06 100644 --- a/docs/META.md +++ b/docs/META.md @@ -784,6 +784,10 @@ PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others don `m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave (+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss` — the leader's entry with its spawn tile `start`, where the boss-field prep shows it —, + its bounties). +During PREP, `prepMapChars` carries the read-only 预备干员-医疗 UnitInfo for every living player's field when any living +player holds 外勤医疗. It uses the stage's normal-board map-character slot; the own board draws it separately from +`board` (so it cannot be moved or sold), and `prepFieldMeta` includes it for scouts. In a boss prep, the same slot is +mapped to each player's half, including `bossMate.units`. Touch replaces this reserve medic only at battle start. Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE, `m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` diff --git a/docs/design/client.md b/docs/design/client.md index 168756eb..61bc511f 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -77,6 +77,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware). - Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas. -- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. +- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncReadOnlyPrepUnits`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. With 外勤医疗 selected, every living player's own prep, paired boss prep and scouted prep shows a read-only 预备干员-医疗 on its stage slot; the strategy's Touch replacement occurs only when combat begins. --- diff --git a/docs/design/network.md b/docs/design/network.md index a1b4867a..a5dcbd8b 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -18,7 +18,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` S→C: - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112) -- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). +- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; during prep, `prepMapChars` shows the 外勤医疗 reserve medic on the stage's pre-existing slot without adding a movable board piece; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board and reserve medic on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index 5359129b..f7641cb6 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -1578,8 +1578,8 @@ Notes for the integrator ### 27.64 外勤医疗's pre-battle map character retains its earlier aggro when Touch replaces it — `content/bands/battle.js installMapChars` -- **Seen**: the user observes 预备干员-医疗 already visible on the field during preparation. With two elite operators, - `battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`; +- **Seen**: the [official recording](https://b23.tv/JZLqdSO) at about 4:25 shows 预备干员-医疗 on the field during preparation, + while this client's preparation view did not show it. With two elite operators, `battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`; a local boss test makes “萨米的意志” select Touch's column first. In the [official recording](https://b23.tv/JZLqdSO) at about 4:57, Touch stands at the right edge while 冰凌 falls on a different column. The recording supports a mismatch, but does not by itself isolate every other target's taunt. @@ -1587,10 +1587,8 @@ Notes for the integrator describes a 预备干员-医疗 appearing on every player's field and being replaced by Touch at combat start if at least two elite operators stand there. PRTS 索敌 places the last deployed target ahead at equal taunt. [ASSUMED]: the replacement retains the map character's pre-deployment aggro position; the sources do not fix its exact numeric rank. -- **Now**: only the 外勤医疗 map character (预备干员-医疗 or Touch) gets an early `aggroSeq` after its `battleStart` - spawn. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and - mid-battle summons retain their existing priority. +- **Now**: in PREP, every living player's own board and scouted board shows a read-only 预备干员-医疗 from the stage's map-character slot when any living player holds 外勤医疗; a boss pair's two medics appear on the respective halves. The prep unit does not become a movable `board` piece. At `battleStart`, only the 外勤医疗 map character (预备干员-医疗 or its Touch replacement) gets an early `aggroSeq`. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and mid-battle summons retain their existing priority. - **Golden**: one value moves in `test/golden/matches.json`: `coop2-NORMAL-14-standins`, player `h_0`'s `dmgDealt` 3,000,543 → 2,999,081. That match fields 外勤医疗, so its changed initial target order can alter damage; every other recorded result is unchanged. **Tests**: `test/content/bands.test.js` checks both variants' priority and - “萨米的意志” 冰凌 selection on solo and two-player boss fields. + “萨米的意志” 冰凌 selection on solo and two-player boss fields; `test/match/touch-prep.test.js` checks own, scouted and paired boss preparation views; the render browser test checks that the medic is visible, mirrored and not draggable. diff --git a/public/js/render/app.js b/public/js/render/app.js index 1c721bd9..88208205 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -797,8 +797,16 @@ export async function createFieldView(host, options = {}) { if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir); } for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); - // the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only) - syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []); + // The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to + // the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates. + const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { + if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; + const p = prepXf.toDisp(u.y, u.x); + const dir = prepXf.dirToDisp(u.dir); + return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 }; + }).filter(Boolean) : []; + const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []; + syncReadOnlyPrepUnits([...mapChars, ...mate]); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) @@ -806,11 +814,10 @@ export async function createFieldView(host, options = {}) { } /** - * The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field - * coordinates, facing as the battle will place them): read-only 'm:' views — not prep pieces, so they are never - * picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes. + * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not prep pieces, so they + * are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes. */ - function syncMates(units) { + function syncReadOnlyPrepUnits(units) { const keep = new Set(); for (const u of units) { const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null; diff --git a/server/match/match/views.js b/server/match/match/views.js index 16e3d355..34942cd1 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -236,10 +236,29 @@ export class MatchViews { return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined; } + /** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at + * battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's + * normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */ + prepMapChars(ps) { + if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return []; + const id = 'char_605_cmedic'; + const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12 + && m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias))); + const rec = this.gd.token(id); + if (!slot || !rec) return []; + const dir = slot.dir || 'RIGHT'; + return [{ + id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id, + area: 'board', name: rec.name || id, tier: 1, golden: false, + spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id, + x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1, + }]; + } + /** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). * `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */ _prepBoardUnits(ps) { - const units = []; + const units = this.prepMapChars(ps); // the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js) const gd = ps.gd || this.gd; for (const { r, c, piece } of boardOrder(ps.board)) { diff --git a/server/match/player/views.js b/server/match/player/views.js index f89dd769..5172eee3 100644 --- a/server/match/player/views.js +++ b/server/match/player/views.js @@ -88,6 +88,8 @@ export class PlayerViews { hand: this.hand.map((p) => (p ? this.pieceView(p) : null)), temp: this.temp.map((p) => (p ? this.pieceView(p) : null)), board, + // The stage's 外勤医疗 reserve medic is present during prep, but is not a draggable board piece. + prepMapChars: this.m.prepMapChars(this), deployCap: this.deployCap, deployCount: this.deployCount, // + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts) diff --git a/test/match/touch-prep.test.js b/test/match/touch-prep.test.js new file mode 100644 index 00000000..399e8c2c --- /dev/null +++ b/test/match/touch-prep.test.js @@ -0,0 +1,47 @@ +// 外勤医疗: the official preparation field already shows 预备干员-医疗; Touch replaces it at battle start. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch } from './harness.js'; + +const MEDIC = 'char_605_cmedic'; +const STAGE = 'act2autochess_m01'; + +test('外勤医疗 shows the reserve medic on every prep board and in prep scouting, without adding a movable piece', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 6301, fake: true }).start().toPrep(); + h.setStage(STAGE); + const [holder, teammate] = [h.ps('p_0'), h.ps('p_1')]; + assert.deepEqual(holder.privateView().prepMapChars, [], 'no medic without the strategy'); + holder.bandId = 'band_amedic'; + for (const ps of [holder, teammate]) { + const priv = ps.privateView(); + assert.equal(priv.prepMapChars.length, 1); + const medic = priv.prepMapChars[0]; + assert.deepEqual([medic.defId, medic.kind, medic.x, medic.y, medic.dir, medic.ownerId], + [MEDIC, 'token', 2, 10, 'RIGHT', ps.playerId]); + assert.ok(medic.uid < 0 && !priv.board.some((p) => p.uid === medic.uid), 'display only: no board piece to move or sell'); + assert.equal(h.m.prepFieldMeta(ps).units.find((u) => u.uid === medic.uid)?.defId, MEDIC); + } + holder.bandId = null; + assert.deepEqual(teammate.privateView().prepMapChars, [], 'the preview goes away when nobody holds the strategy'); + h.m.dispose(); +}); + +test('外勤医疗 boss preparation shows both reserve medics on their matching halves, mirrored for the right player', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 6302, fake: true }).start().toPrep(); + h.setStage(STAGE); + const m = h.m; + m.bossWaves = [{ players: ['p_0', 'p_1'], wave: { spawns: [] } }]; + h.ps('p_0').bandId = 'band_amedic'; + const [left, right] = [h.ps('p_0'), h.ps('p_1')]; + const own = left.privateView().prepMapChars[0]; + const mate = left.privateView().bossMate.units.find((u) => u.defId === MEDIC); + assert.deepEqual([own.y, own.x, own.dir], [10, 2, 'RIGHT'], 'own prep uses board coordinates'); + assert.deepEqual([mate.y, mate.x, mate.dir, mate.ownerId], [3, 18, 'LEFT', 'p_1']); + const scout = m.prepFieldMeta(right); + assert.equal(scout.kind, 'boss'); + const medics = scout.units.filter((u) => u.defId === MEDIC).sort((a, b) => a.x - b.x); + assert.deepEqual(medics.map((u) => [u.y, u.x, u.dir, u.ownerId]), + [[3, 2, 'RIGHT', 'p_0'], [3, 18, 'LEFT', 'p_1']]); + assert.notEqual(medics[0].uid, medics[1].uid, 'both model IDs remain distinct on the shared field'); + m.dispose(); +}); diff --git a/test/render/browser.test.js b/test/render/browser.test.js index 810503b7..2f46ec04 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -73,6 +73,46 @@ describe('render engine in headless Chrome', { skip }, () => { }); } + test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => { + const { page, problems } = await open('scene=prep&panel=0', 1600, 900); + const first = await page.evaluate(() => { + const v = window.__demo.view; + const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); + st.prepMapChars = [{ id: -1, uid: -1, kind: 'token', side: 'ally', ownerId: 'p_0', + defId: 'char_605_cmedic', name: '预备干员-医疗', spine: 'char_605_cmedic', avatar: 'char_605_cmedic', + area: 'board', x: 2, y: 10, dir: 'RIGHT', facing: 1, maxHp: 1202 }]; + v.setPrep(st, { editable: true }); + const medic = v.debug.views.get('m:-1'); + const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y, + draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) }; + return out; + }); + await wait(500); + await page.screenshot({ path: path.join(OUT, 'touch-prep-normal.png') }); + const boss = await page.evaluate(() => { + const v = window.__demo.view; + v.setCamera('bossPrep', { side: 'R', instant: true }); + const mirrored = v.debug.views.get('m:-1'); + return [mirrored?.x, mirrored?.y, mirrored?.dir]; + }); + await wait(500); + await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') }); + const removed = await page.evaluate(() => { + const v = window.__demo.view; + const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); + st.prepMapChars = []; + v.setPrep(st, { editable: true }); + return !v.debug.views.has('m:-1'); + }); + await page.close(); + assert.deepEqual(problems, []); + assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10, + draggable: false }); + assert.deepEqual(boss, [18, 3, 'LEFT']); + assert.equal(removed, true); + }); + test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => { const { page, problems } = await open('scene=fx&panel=0', 1280, 720); // cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots