From 231da0efbc1fbec8a9c9367b01a0849bf305539b Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 13 Oct 2026 04:36:56 +0800 Subject: [PATCH] =?UTF-8?q?0.2.6-dev:=20port=20PR=20#416=20by=20Cromemadnd?= =?UTF-8?q?=20=E2=80=94=20at=20every=20round=20start=20the=20own=20board's?= =?UTF-8?q?=20pieces=20replay=20their=20Start=20clip=20and=20landing=20sou?= =?UTF-8?q?nd=20one=20after=20another=20in=20deployment=20order=20(the=20m?= =?UTF-8?q?aintainer's=20confirmation=20from=20official=20play,=202026-10-?= =?UTF-8?q?13),=20and=20a=20purchase=20or=20a=20move=20onto=20/=20across?= =?UTF-8?q?=20the=20board=20plays=20them=20too=20([ASSUMED],=20the=20PR=20?= =?UTF-8?q?author's=20account);=20one=20sound=20per=20landing,=20idle=20pr?= =?UTF-8?q?ep=20pieces=20only,=20the=20round=20marked=20once=20the=20view?= =?UTF-8?q?=20took=20the=20sequence,=20own=20board=20only,=20timers=20clea?= =?UTF-8?q?red=20when=20prep=20mode=20ends=20and=20on=20destroy;=20DESIGN?= =?UTF-8?q?=20=C2=A731.8;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/PLAYING.md | 1 + docs/design/client.md | 31 +++- docs/history/0.2.6.md | 59 ++++++++ public/js/audio.js | 26 ++++ public/js/render/app.js | 92 ++++++++++-- public/js/render/app/tune.js | 7 + public/js/render/units.js | 55 ++++++- public/js/screens/game.js | 17 ++- public/js/ui/fieldHost.js | 5 +- public/js/ui/gameLogic.js | 2 +- public/js/ui/gameLogic/phases.js | 39 ++++- test/render/deploy-replay.test.js | 203 ++++++++++++++++++++++++++ test/render/playtest6-promote.test.js | 3 +- test/ui/deploy-replay.e2e.test.js | 142 ++++++++++++++++++ test/ui/deploy-replay.test.js | 130 +++++++++++++++++ 16 files changed, 791 insertions(+), 22 deletions(-) create mode 100644 test/render/deploy-replay.test.js create mode 100644 test/ui/deploy-replay.e2e.test.js create mode 100644 test/ui/deploy-replay.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b23a4fe..7359941c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### 界面 - 官方战场场景的光照改回提取出的官方值:直射光和烘焙光不再被压暗,场景不再明显偏暗(#549,PR #554,感谢 @xcdoge)。 - 最后一个还没选策略的玩家不再看到可点的「跳过」按钮:已经选完的玩家仍显示在顺序里,但不算可以让过去的人(PR #546,感谢 @Xinglan233)。 +- 每回合开始时,自己棋盘上的干员按部署顺序一个接一个重播入场动画并播放落地音效(维护者按官方实机确认);买到干员、干员上场或换到另一格时也会播放一次,同一次落地只响一声,音量跟随「音效」(PR #416,感谢 @Cromemadnd)。 ### 工具与开发 - Windows 下场景素材测试的仓库路径不再重复盘符(PR #547,感谢 @Xinglan233)。 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f25892df..6ba56e89 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -77,6 +77,7 @@ - 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。 - **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。 - 拖动时**指针 / 手指所在的格子**就是落点并高亮。这一格可以放下(棋盘或整备区)时,干员模型直接站在这一格上,和官方部署一样:手指在格子里移动时模型不动,移到下一个可放下的格子就站到那一格;指针不在可放下的格子上时(例如在商店栏上),模型跟在指针 / 手指下(脚在指针下方约半格)。鼠标和触屏一样。装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 +- **入场动画与落地音效**(0.2.6,PR #416):每回合开始(「回合开始」)时,你棋盘上的干员按部署顺序(和开战时一样:逐列从左到右、每列从上到下,召唤物在干员之后)一个接一个重播入场动画,各响一声落地音效(维护者按官方实机确认)。买到干员(新干员出现在整备区)、把干员放上棋盘或换到另一格时也会播放一次(按 PR 作者对官方的描述);原地改朝向、撤退回整备区、在整备区里挪动不播放,同一次落地只响一声。音量跟随「音效」,静音时不响;只在你自己的棋盘上播放,查看队友的阵地或观战时不播放。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、按你在「干员调配」里给这名干员设的**潜能和练度**(默认潜能 6、精英阶段2-60级)、0 信赖的数值:潜能加成包括部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋按召唤者的潜能;练度把攻击、防御、生命上限单独乘上自持有加成(精英阶段2-60级 ×1.1;召唤物不乘)。补位的替补干员和原型干员没有潜能档,也没有自持有加成(0.2.2;0.2.1 是所有干员一律满潜能、没有自持有加成,GitHub #252)。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 diff --git a/docs/design/client.md b/docs/design/client.md index 65472eb5..07c7cedd 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -19,7 +19,9 @@ view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side, shop }) // folded shop's prep camera (official shop-collapsed framing, §21.27) view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag; // a merge's new elite on the board plays fx.promote instead of fx.deploy (render/promote.js, §20.11), - // also after a battle (a merge between two preps: the last prep state is kept, §20.14) + // also after a battle (a merge between two preps: the last prep state is kept, §20.14); + // an operator new on the bench, a merge's elite and a piece put on another board tile land (§31.8) +view.replayDeploys(uids) // the round start: the own board's pieces land one after another, 50 ms apart (§31.8) view.enterBattle(fieldMeta) // switch to battle rendering for m.field view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer) view.highlightTiles(tiles, style) // legal/illegal placement, range preview @@ -67,7 +69,7 @@ its board-space position and facing, including on a mirrored boss half. They can from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates the selected variant at battle start (§30.22). -Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off). +Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), a prep piece's landing sound (its battle deploy sound, once per landing, §31.8), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off). --- @@ -159,8 +161,8 @@ an unknown unit, and a late-join snapshot restores the current mode. A mode with sound. A selected mode with no mix uses its own default volume/probability, not the ordinary bank's mix. A model that arrives after an actual deployment continues Start at the elapsed game time; after the clip's duration it -shows the current base pose. A subsequent attack/death supersedes that pending entrance. No extra deployment sound -or per-phase animation schedule is added by this change. +shows the current base pose. A subsequent attack/death supersedes that pending entrance. That change (0.2.3) added no +deployment sound or per-phase animation schedule; the prep landings of §31.8 below add both for prep pieces. The simplified-view notice describes a renderer load failure, rather than declaring the device incapable of 3D. On same-battle metadata refresh, remembered once-spawned device records also seed the renderer's new entry (§29.14). @@ -192,3 +194,24 @@ while 「显示伤害数字」 is also on, the field draws a red integer for a ` for a dodge fx whose payload is `num: 'm'` (PRTS 作战机制 「伤害红字(暴击)」). Ordinary hits and heals draw no digits in that mode. The mark is computed in the sim; the random offset above the model is client-side. The simplified board uses the same mark with a fixed slot. The title footer links the project repository in a new tab. + +### Deploy animation replays (§31.8) + +PR #416 by @Cromemadnd. A prep piece that lands plays its Start clip again with its landing sound, once per landing +(`render/units.js UnitView.land`): the sound its battle deployment makes (`audio.js deployCue` → `deploySfxUrl`, its own +ON_UNIT_BORN file else b_char_set / b_char_tokenset) through the SFX volume, mute and the battle limiter. A piece whose +Start still plays (on its model, owed to a model still loading, or cued less than a clip ago) keeps it and sounds nothing +more. Only an idle prep piece lands, never a battle unit or one attacking, dying, dragged or in another clip, and nothing +lands in a hidden tab. Battle deploys keep the silent `onDeploy`; audio.handleBattleEvents sounds them. + +- Round start (the maintainer's confirmation from official play, 2026-10-13): during 回合开始 the own board's pieces land + one after another in deployment order — operators by column from the left, top to bottom within a column, then the + summons (`ui/gameLogic deployOrder`, the order of Battle.start) — `DEPLOY_REPLAY_MS` 50 ms apart [ASSUMED], chained + timeouts (`render/app.js replayDeploys`). `screens/game.js` asks once its field-view wiring has run and marks the round + only when the view has taken the sequence (`roundStartReplay`): a view or board not built yet is asked again during + 回合开始, a resync or re-render of the same round replays nothing. Own board only: never a scouted or watched + teammate's board, a spectator seat, an eliminated player or the boss partner's half. The timers end with prep mode, a + fresh prep scene and `destroy()`. +- [ASSUMED] (the PR author's account): an operator that arrives on the bench (a purchase, a reward, a grant) and a merge's + elite land; so does a piece that a new state puts on a board tile it was not on (from the bench, another tile, a swap), + when that state arrives. Turning in place, a retreat and bench moves do not. diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 054d8808..f8b6fd73 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -117,3 +117,62 @@ and its glass, which were matched to screenshots, and does not touch the scene m lightmap page) stays [ASSUMED]. The water (#549's second half, PR #555) is unchanged here. Tests: `test/render/board3d-scene.test.js`; the 3D board browser suite with the extracted scenes. + +### 31.8 Deploy animation replays (PR #416) + +PR #416 by @Cromemadnd, written against 0.2.2 and ported onto the current files. + +**Seen.** A prep piece never played its Spine Start clip or a landing sound outside a battle. A piece put on the board +through the direction wheel played nothing: the state that shows the move arrives while the wheel's hold still pins the +piece on its tile, so `setPrep`'s branch for a piece entering the board was never reached. A purchase only flashed +(`fx.deploy`), and the board rebuilt after every battle stood still. + +**Official / decision.** The maintainer confirmed from official play on 2026-10-13 that at the start of every round the +operators on the board replay their Start animation and landing sound one after another in deployment order. The PR also +plays them when an operator is bought and when it enters the board or swaps tiles; its author says the official client +does so. Those two triggers are [ASSUMED] on that account. The PR's late-model case (a deploy asked before the Spine model +has loaded) was fixed in 0.2.3 (§28.22: the late model continues Start at the elapsed game time) and stays as it is. + +**Now.** + +- `UnitView.land()` (render/units.js) plays the piece's Start clip again through `onDeploy` (its fade-in and the late-model + continuation) and asks `audio.deployCue` for the landing sound: the piece's battle deploy sound (`deploySfxUrl`: its own + ON_UNIT_BORN file at its bank gain, else b_char_set / b_char_tokenset at 0.5) through the SFX channel's volume and mute + and the battle limiter (≤ 2 copies of one sound, a 160 ms cooldown per piece). One landing makes one sound: a piece + whose Start still plays keeps it and sounds nothing more, whether the clip runs on its model, is owed to a model still + loading, or was cued less than a clip length ago. That covers a purchase followed by a move, and the round start's replay + reaching a piece just put down. Only an idle prep piece lands. A battle unit never does: the sim's `deploy` events keep + the silent `onDeploy`, and audio.handleBattleEvents makes their sound (the PR's view-side sound would have doubled every + battle deployment). Nor does a piece that is attacking, dying, lifted by a drag or in another clip, or any piece while the + tab is hidden (the clip and the suspended audio would play it stale on return). +- Round start (`ui/gameLogic roundStartReplay`, `screens/game.js`, `render/app.js replayDeploys`). During 回合开始 the own + board's pieces land one after another in deployment order, each turn chained 50 ms after the one before. The order is the + battle's (`deployOrder`, as Battle.start): operators by column from the left, top to bottom within a column, then the + summons the same way, in board coordinates. The PR used the m.private board list, which is reading order. The screen + asks after its field-view wiring effect, so the view already holds the round's pieces, and it marks the round only once + the view has taken the sequence. A view still loading or a board not built yet is asked again on the next render of + 回合开始; the PR marked the round first, so a late view never replayed. A re-pushed state (a reconnect's resync) or a + re-render of the same round plays nothing more, and nothing is replayed outside 回合开始. The timers end with prep mode + (`enterBattle`, which a scouted board also uses), with a fresh prep scene and with `destroy()`; a turn whose piece is + gone or rebuilt passes over it. +- Spectators and watched boards: the replay plays on the viewer's own board only. A scouted or watched teammate's prep + board, a spectator seat (no m.private), an eliminated player and the boss partner's half in a Final Assault prep get none. +- Purchase and board triggers (`render/app.js setPrep`). An operator that has just arrived on the bench lands: a purchase, + a reward or a grant, and a merge's elite on its bench slot or on the copy's tile. A summon that arrives with its owner's + placement does not, nor does an item. A piece that a new state puts on a board tile it was not on lands when that state + arrives, whether it came from the bench, from another tile or as the partner of a swap. The PR also landed a piece at + the wheel's confirm, which sounded twice whenever the server answered after 0.3 s, and on every move on the bench and + every retreat. Here turning in place, a retreat and bench moves do not land. That is narrower than the PR's wheel hook, + which fired on every confirm. The deploy flash (`fx.deploy`) is unchanged. + +**[ASSUMED]** The purchase and board triggers (the PR author's account). The 50 ms gap (`DEPLOY_REPLAY_MS`, the PR's +value; no source gives the official one). Summon pieces taking part in the replay after the operators, as in the battle's +deployment. The clip length a landing counts as playing when the model's own is unknown (`START_CLIP_FALLBACK` 1 s; the +operators' Start clips run 0.77–1.2 s). A page reloaded during 回合开始 replays once in the new page. + +**Tests.** `test/render/deploy-replay.test.js` (the clip, one sound, a second trigger, a late model, the refusals, a hidden +tab; the app.js wiring), `test/ui/deploy-replay.test.js` (`deployOrder`, `roundStartReplay`, the screen wiring; `deployCue`: +the files, the limiter keys, volume and mute), `test/render/playtest6-promote.test.js` (the elite's cue), and the browser +check `test/ui/deploy-replay.e2e.test.js` (`SP_E2E=1`, mock harness: SETTLE → 回合开始 replays the board in deployment order +about 50 ms apart with one sound each, once per round and again the next round; in the 休整期 a purchase lands, a move +within its Start sounds nothing more, a later move lands, a turn in place does not). Golden: unchanged (client only). diff --git a/public/js/audio.js b/public/js/audio.js index 6feeaa7b..5d26598a 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -62,6 +62,9 @@ // - Deaths/deployments follow the official per-class defaults (unitSoundClass): only operators play the // operator-knocked-down sound; summons use the token sounds; a summon used up by its own effect (fx `consumed`, // 香槟炸弹) plays its impact sound instead of a death sound. +// - A prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's replay of the own +// board, a purchase, a move onto or across the board) makes its battle deploy sound once (`deployCue`): the same file +// and gain, the battle limiter and the SFX channel's volume and mute. // - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning). // // `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`, @@ -1127,6 +1130,29 @@ export class AudioManager { if (typeof url === 'string') this._play(url, { volume, limited: true, unitKey }); } + /** + * The landing sound of a prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's + * replay of the own board, a purchase, a move onto or across the board): the sound its battle deployment makes + * (deploySfxUrl — its own ON_UNIT_BORN sound with the bank's mix, else b_char_set / b_char_tokenset) at the same gains, + * through the SFX channel (volume, mute) and the battle limiter (≤ 2 copies of one sound, a cooldown per piece). + * @param {{ def?: string|null, defId?: string|null, kind?: string|null }} info the piece: its model id (the sfx.units + * key), its chess / token id and its kind ('op' | 'token' — the official sound class, unitSoundClass) + * @param {string|number} key the piece (its cooldown key) + * @returns {boolean} whether the piece has a landing sound (a silent roll of its bank still counts) + */ + deployCue(info, key) { + try { + const m = this.getManifest(); + const u = { def: info?.def ?? null, defId: info?.defId ?? null, kind: info?.kind ?? null, side: 'ally' }; + const url = deploySfxUrl(m, u); + if (!url) return false; + const own = url === m?.audio?.sfx?.units?.[u.def]?.born; + const mix = own ? m.audio.sfx.units[u.def].mix?.born : null; + if (unitSoundPlays(mix, this.random())) this._playUnitUrl(url, `land:${key}`, own ? unitGain(0.8, mix) : 0.5); + return true; + } catch { return false; } + } + /** * React to `b.ev` tuples (DESIGN §8.2). * @param {any[]} ev diff --git a/public/js/render/app.js b/public/js/render/app.js index a8cb77f9..0cbe6949 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -14,7 +14,15 @@ // view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a // new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; a merge's elite — on the tile of // the deployed copy it replaced, or on its bench slot — gets the -// promotion cue instead (render/promote.js, fx.promote) +// promotion cue instead (render/promote.js, fx.promote). Landings (PR #416, +// UnitView.land: the Start clip again + the landing sound, once): an +// operator that just arrived on the bench (a purchase, a reward, a grant) +// and a merge's elite [ASSUMED], a piece this state puts on a board tile it +// was not on — from the bench, from another tile, a swap — [ASSUMED] +// view.replayDeploys(uids) the round start: the own board's pieces `uids` (deployment order) land one +// after another, DEPLOY_REPLAY_MS apart (the maintainer's confirmation from +// official play, 2026-10-13) → how many of them the board holds now (0: not +// built yet); the timers end with prep mode (enterBattle) and destroy // view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each // unit through renderInfo(UnitInfo) (an enemy's `form`: a view built // mid-battle starts in the current model form) @@ -134,14 +142,14 @@ import { pickOnTile, pickBattle, hitTiles } from './pick.js'; import { promotionsOf } from './promote.js'; import { ensurePixi } from './app/pixi.js'; import { pieceDirOf, pickUnitOf } from './app/pick.js'; -import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS } from './app/tune.js'; +import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS, DEPLOY_REPLAY_MS } from './app/tune.js'; import { boardPreference, switchableBox, bandFor, fieldRows, boardArea, viewKind, penShown, leaderShown } from './app/view.js'; import { renderInfo, FORCED_EXIT, showsDeathFx } from './app/info.js'; import { resolveAssets, makeData, withTimeout, QUALITY_RES, BOARD_RES, releaseGl } from './app/host.js'; import { t } from '../../../shared/i18n.js'; export { ensurePixi } from './app/pixi.js'; -export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES } from './app/tune.js'; +export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, DEPLOY_REPLAY_MS } from './app/tune.js'; export { viewKind, boardArea, bandFor, fieldRows, penShown, leaderShown, boardPreference, switchableBox } from './app/view.js'; export { FORCED_EXIT, showsDeathFx, renderInfo } from './app/info.js'; export { releaseGl } from './app/host.js'; @@ -270,6 +278,8 @@ export async function createFieldView(host, options = {}) { // or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b) let promoBase = []; const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests + const landings = []; // the last prep landings (UnitView.land, PR #416): { uid, why: 'round'|'buy'|'merge'|'board', at } — dev / tests + let replayTimer = 0; // the next turn of the round start's replay (replayDeploys): cleared when prep mode ends and on destroy let battleMeta = null; let sceneHold = null; // release function of the Spine cache hold of battle mode (holdScene) const infos = new Map(); // battle unit id → UnitInfo @@ -308,6 +318,7 @@ export async function createFieldView(host, options = {}) { const heightAt = (r, c) => (tiles ? tiles.heightAt(r, c) : 0); const ctx = { P, layers, assets, settings, fx: null, shadowTex: shadowTexture(), + audio: opts.audio || null, // the prep landings' sound (UnitView.land → audio.deployCue) cam: () => cam, heightAt, animRate: () => (mode === 'battle' ? interp.rate : 1), timeScale: () => (mode === 'battle' ? interp.rate : 1), @@ -748,6 +759,9 @@ export async function createFieldView(host, options = {}) { addList(src.board, 'board'); const seen = new Set(); const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid)); + // where each piece stood in the last state of this prep (nothing after a battle rebuilt the scene): a piece this state + // puts on a board tile it was not on lands (PR #416) + const prevAt = new Map(prepPieces.map((p) => [p.uid, p])); // merges since the last state (render/promote.js): new elite uid → where its consumed copies stood (their views // still exist until the sweep below) — the elite gets the promotion cue instead of the deploy flash const promoFrom = new Map(); @@ -782,13 +796,18 @@ export async function createFieldView(host, options = {}) { v.setWorld(w.x, w.y, w.z); views.set(key, v); if (promoFrom.has(e.uid)) { - // a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot - if (e.area === 'board') v.onDeploy?.(); + // a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot — it lands there (PR #416) + landPiece(v, 'merge'); fx.promote(v, promoFrom.get(e.uid).filter((f) => Math.abs(f.x - w.x) + Math.abs(f.y - w.y) > 1e-3)); promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length }); if (promotions.length > 20) promotions.shift(); - } else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); } - else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) fx.deploy(v); + } else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { landPiece(v, 'board'); fx.deploy(v); } + else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) { + // an operator that just arrived on the bench — bought, a reward, a grant — lands there (PR #416); a summon that comes + // with its owner's placement does not, nor does an item + if (e.piece.kind === 'chess') landPiece(v, 'buy'); + fx.deploy(v); + } } else { const prevHome = v._home; const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z; @@ -800,8 +819,13 @@ export async function createFieldView(host, options = {}) { pending.delete(key); if (moved || Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 }; v.lift = 0; - if (e.area === 'board' && !prevBoard.has(e.uid) && v.onDeploy) { v.onDeploy(); fx.deploy(v); } + if (e.area === 'board' && !prevBoard.has(e.uid) && v.onDeploy) fx.deploy(v); } + // a piece this state puts on a board tile it was not on — from the bench, from another tile, a swap — lands (PR #416), + // also while its drop is still held on that tile (the direction step pins it there, so `moved` is false); turned in + // place, retreated or moved on the bench it does not + const was = prevAt.get(e.uid); + if (was && e.area === 'board' && (was.area !== 'board' || was.row !== e.row || was.col !== e.col) && !(dragState && dragState.key === key)) landPiece(v, 'board'); } v._home = w; v.dimmed = false; @@ -830,6 +854,52 @@ export async function createFieldView(host, options = {}) { return true; } + /** Land a prep piece (UnitView.land, PR #416) and note it (`landings`, dev / tests); false when the view refused. */ + function landPiece(v, why) { + if (!v || typeof v.land !== 'function' || !v.land()) return false; + landings.push({ uid: v.uid ?? null, why, at: performance.now() }); + if (landings.length > 40) landings.shift(); + return true; + } + + /** + * The round start's replay (see the header; PR #416): the listed pieces of the own board land one after another in the + * order given (deployment order, ui/gameLogic deployOrder), each turn DEPLOY_REPLAY_MS after the one before (a chain of + * timeouts, so a busy frame never squeezes two together). A piece gone, rebuilt, not idle or already landing when its + * turn comes is passed over (UnitView.land), and none lands while the tab is hidden (no frame shows it; the suspended + * audio would play the sound on return). A new sequence replaces one still running; prep mode ending and destroy() + * cancel it. + * @param {number[]} uids + * @param {number} [gap] ms between two pieces + * @returns {number} how many of `uids` the board holds now — 0 outside prep mode or before setPrep built them + */ + function replayDeploys(uids, gap = DEPLOY_REPLAY_MS) { + if (destroyed || mode !== 'prep' || !Array.isArray(uids)) return 0; + const step = Number.isFinite(gap) && gap >= 0 ? gap : DEPLOY_REPLAY_MS; + const queue = []; + for (const uid of uids) { + const v = Number.isInteger(uid) ? views.get('p:' + uid) : null; + if (v && !v.destroyed && typeof v.land === 'function' && !queue.some((q) => q.v === v)) queue.push({ uid, v }); + } + if (!queue.length) return 0; + cancelReplay(); + let i = 0; + const turn = () => { + replayTimer = 0; + if (destroyed || mode !== 'prep') return; + const { uid, v } = queue[i++]; + if (!globalThis.document?.hidden && views.get('p:' + uid) === v) landPiece(v, 'round'); + if (i < queue.length) replayTimer = setTimeout(turn, step); + }; + replayTimer = setTimeout(turn, 0); + return queue.length; + } + + function cancelReplay() { + if (replayTimer) clearTimeout(replayTimer); + replayTimer = 0; + } + /** * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not movable prep pieces; * only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes. @@ -874,6 +944,7 @@ export async function createFieldView(host, options = {}) { function enterPrepMode() { mode = 'prep'; held.clear(); + cancelReplay(); clearViews(); infos.clear(); gone.clear(); @@ -1323,6 +1394,7 @@ export async function createFieldView(host, options = {}) { holdScene(true); // before the prep views go: the cache is never "quiet" across the switch drag.reset(); endDragVisual(false); + cancelReplay(); // the round start's replay is a prep-board thing: none of it reaches a battle (or a scouted board) clearViews(); if (prepPieces.length) promoBase = prepPieces; prepPieces = []; @@ -1816,6 +1888,7 @@ export async function createFieldView(host, options = {}) { setStage, setCamera, setPrep, + replayDeploys, /** Enemy preview pen: a list in m.private.nextEnemies shape, or null to empty it (setPrep does this itself). */ setPen(list) { if (destroyed) return false; setPenList(Array.isArray(list) ? list : null); return true; }, enterBattle, @@ -1936,6 +2009,7 @@ export async function createFieldView(host, options = {}) { app.ticker.remove(preRender); app.ticker.remove(postRender); clearTimeout(recover.timer); + cancelReplay(); drag.reset(); for (const k of [...views.keys()]) dropView(k); clearPen(); @@ -1964,7 +2038,7 @@ export async function createFieldView(host, options = {}) { /** Dev hooks (demo / tests). */ debug: { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); }, - promotions, + promotions, landings, get replaying() { return !!replayTimer; }, // picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile }, }, diff --git a/public/js/render/app/tune.js b/public/js/render/app/tune.js index e0fe5e0d..bca87a07 100644 --- a/public/js/render/app/tune.js +++ b/public/js/render/app/tune.js @@ -32,4 +32,11 @@ const DROP_PENDING_MS = 1300; */ export const DRAG_HOLD_TILES = 0.45; +/** + * The round start's replay of the own board (view.replayDeploys, PR #416 by @Cromemadnd): ms between two pieces' Start + * clips, in deployment order. The maintainer saw them come one after another in official play (2026-10-13); [ASSUMED] the + * gap — the PR's 50 ms, no source gives the official one. + */ +export const DEPLOY_REPLAY_MS = 50; + export { CAMERA_MS, BOARD3D_RETRY_MS, BOARD3D_STABLE_MS, RANGE_GROUPS, CHAIN_KINDS, DROP_PENDING_MS }; diff --git a/public/js/render/units.js b/public/js/render/units.js index adf55a38..88719970 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -35,6 +35,13 @@ // redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out. // An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit') // goes down with `die(true)`: straight to the held end of the clip, no fall. +// Prep landings (PR #416 by @Cromemadnd): `land()` plays a prep piece's deploy clip (Start) again with its landing sound +// — the round start's replay of the own board (render/app.js replayDeploys), a purchase, a move onto or across the board. +// One landing, one sound: a piece whose Start still plays (on its model, owed to a model still loading, or cued less than +// a clip ago) keeps it and sounds nothing more, and only an idle prep piece is ever pulled into it — never a battle unit +// or one in an attack, skill, death or form clip. The sound is `ctx.audio.deployCue` (public/js/audio.js: the unit's own +// battle deploy sound, through the SFX volume, mute and the battle limiter); `onDeploy` itself — the sim's 'deploy' +// events — stays silent: audio.handleBattleEvents sounds those. // Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its // bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像 @@ -149,6 +156,11 @@ export const SPINE_RETRY_MS = Object.freeze([2000, 6000, 15000, 30000]); * [ASSUMED] the length: a healthy load finishes within a few seconds once the tab is visible. */ export const SPINE_STUCK_MS = 5000; +/** + * Seconds a prep landing (UnitView.land) counts as playing when the model's Start clip length is unknown (no manifest entry + * yet): the operators' Start clips run 0.77–1.2 s, most of them 1 s (data/assets.json). [ASSUMED] + */ +export const START_CLIP_FALLBACK = 1; /** Heights above this count as standing on a raised top (bench pads are the lowest raised tiles, 0.16). */ const RAISED_Z = 0.12; @@ -447,9 +459,9 @@ function bar(P, parent, color, alpha = 1) { } /** - * Shared context handed to every view by app.js. + * Shared context handed to every view by app.js (`audio`: the audio manager of public/js/audio.js — prep landings, land()). * @typedef {{ P: any, layers: { shadow: any, units: any, bars: any, groundFx: any }, - * assets: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number, + * assets: any, audio?: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number, * settings: { damageNumbers: boolean, quality: string } }} ViewCtx */ @@ -565,6 +577,7 @@ export class UnitView { this.spineReady = false; this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel) this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start + this._landUntil = 0; // a prep landing (land) owns the Start clip until then (ms, performance clock) this._spineBusy = false; // a Spine load of this view is in flight this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS) this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none) @@ -1060,6 +1073,44 @@ export class UnitView { if (this.actor) this.actor.deploy(); } + /** + * A prep piece lands (PR #416 by @Cromemadnd; see the header): its Start clip again — a model still loading continues it + * by the time gone (onDeploy, 0.2.3) — and its landing sound, once. Refused (false) for anything but an idle prep piece + * (a battle unit, one dying, lifted by a drag, or in another clip) and for a piece whose Start already plays: a second + * trigger of the same landing — a purchase then a move, the round start's replay over a piece just put down — changes + * nothing and sounds nothing. Refused in a hidden tab too: no frame shows it there, and the clip and the suspended audio + * would only play it, stale, once the tab is back. + * @returns {boolean} whether the Start clip started and the landing sound was asked for + */ + land() { + if (this.destroyed || !this.prep || this.isEnemy || !this.alive || this.dying > 0 || this.lift > 0) return false; + if (globalThis.document?.hidden) return false; + if (this.deploying()) return false; + if (this.actor && this.actor.mode !== 'base') return false; + this.onDeploy(); + this._landUntil = nowMs() + this._startDur() * 1000; + try { this.ctx.audio?.deployCue?.({ def: this.info.spine || this.info.defId, defId: this.info.defId, kind: this.info.kind }, this.id); } catch { /* the sound never stops the landing */ } + return true; + } + + /** + * Whether the Start clip plays now: on the model, owed to a model still loading (onDeploy), or a landing (land) cued less + * than a clip ago — the last covers a model without a Start clip and a view without a model. + */ + deploying() { + if (this.actor && this.actor.deployElapsed() != null) return true; + if (!this.actor && this._pendingDeployElapsed != null && this._pendingDeployElapsed < this._startDur()) return true; + return nowMs() < this._landUntil; + } + + /** Seconds of the Start clip of the model asked for (its manifest entry), START_CLIP_FALLBACK without one. */ + _startDur() { + const e = this.entry; + const name = e && e.anims && typeof e.anims.deploy === 'string' ? e.anims.deploy : null; + const d = name && e.animations && Object.hasOwn(e.animations, name) ? Number(e.animations[name]) : NaN; + return Number.isFinite(d) && d > 0 ? d : START_CLIP_FALLBACK; + } + onStatus(key, on) { if (typeof key !== 'string') return; if (on) this.statuses.add(key); else this.statuses.delete(key); diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 4e1bdaed..a6a59729 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -52,6 +52,10 @@ // reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the // deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand); // the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote). +// Round start (PR #416 by @Cromemadnd; the maintainer's confirmation from official play, 2026-10-13): in 回合开始 the own +// board's pieces replay their Start clip and landing sound one after another in deployment order, once per round and on +// the own board only (gameLogic roundStartReplay → render/app.js replayDeploys); a purchase and a move onto or across the +// board play it too ([ASSUMED], the PR author's account: render/app.js setPrep). // The bond strip, its popup and a unit card's bond chips follow the player on screen (DESIGN §20.15, ui/watchBonds.js): // a scouted / watched / auto-observed teammate's field → that teammate's bonds (m.public players[].bonds, tagged // "👁 name"); 联防 / 最终攻势 → the player on the ‹ › half (全景: yours when you fight there, else the teammate picked @@ -99,7 +103,7 @@ import { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, - mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn, + mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn, roundStartReplay, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -516,6 +520,17 @@ function MatchScreen() { } }, [view, showPrep, priv, editable, field, combat, mode, watchingOther, watching, holdSeq]); + // the round start (PR #416 by @Cromemadnd; the maintainer's confirmation from official play, 2026-10-13): the own board's + // pieces replay their Start clip and landing sound one after another in deployment order — once per round, in 回合开始, on + // the own board only (gameLogic roundStartReplay). After the wiring effect above, so the view already holds this state's + // pieces; the round counts as replayed only once the view took the sequence (a view still loading or a board not built + // yet is asked again on the next render of the phase), and a reconnect or a re-render of the round plays nothing more. + const replayedRef = useRef(null); + useEffect(() => { + const plan = roundStartReplay({ phase, round: pub?.round, replayed: replayedRef.current, ownBoard: showPrep && alive && !spectator, board: priv?.board }); + if (plan && view && view.replayDeploys?.(plan.uids) > 0) replayedRef.current = plan.round; + }, [view, phase, pub?.round, priv, showPrep, alive, spectator]); + // battle frames straight from the socket (server-run combat, 20 Hz) or from the local simulation (client-side combat, // battle/runner.js, every animation frame) — never through the store. Frames go to the view as received: the game // time travels as `gt` and the render engine reads it (render/interp.js frameTime). diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index 4b8e6e1a..04da6148 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -18,8 +18,9 @@ const LOAD_TIMEOUT_MS = 12000; const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize', 'destroy']; // direction-step hooks (ui/facingWheel.js): optional — the wheel falls back to the engine's dev hooks when absent; // setPen (enemy preview pen list), prepField ({ kind, side, mirror } of the Final Assault prep), stripesUnder (the view -// stripes range previews under the units itself) — render/app.js; the DOM fallback lacks them (→ null) -const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder']; +// stripes range previews under the units itself), replayDeploys (the round start's Start-clip replay, PR #416) — +// render/app.js; the DOM fallback lacks them (→ null) +const OPTIONAL = ['pieceScreenRect', 'setSettings', 'off', 'tileScreen', 'holdPiece', 'setPieceDir', 'setPen', 'prepField', 'stripesUnder', 'replayDeploys']; /** * Camera padding (px) that keeps the field clear of the DOM HUD (top bar + bond strip, team panel, shop bar / diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index d4c04b2f..84700a55 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -27,7 +27,7 @@ export { clamp, tileKey } from './gameLogic/shared.js'; export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js'; export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js'; -export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js'; +export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds, deployOrder, roundStartReplay } from './gameLogic/phases.js'; export { RESULT_BOX_MS, ownRoundLoss, battleOverSfx, roundResultBox, uniteResultBox, battleResultBox } from './gameLogic/phases.js'; export { STATUS_META } from './gameLogic/format.js'; export { sortedPlayers } from './gameLogic/shared.js'; diff --git a/public/js/ui/gameLogic/phases.js b/public/js/ui/gameLogic/phases.js index 1f3d5ccb..33a32088 100644 --- a/public/js/ui/gameLogic/phases.js +++ b/public/js/ui/gameLogic/phases.js @@ -1,5 +1,5 @@ -// ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns. Re-exported -// from ../gameLogic.js. +// ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns, the round +// start's deploy replay. Re-exported from ../gameLogic.js. import { PHASE } from '../../../../shared/constants.js'; import { bossLevelSeconds } from '../matchStatus.js'; @@ -177,6 +177,41 @@ export function prepCapsuleLabel(phase) { return t('休息一下'); } +// ---- the round start's deploy replay (PR #416) --------------------------------------------------------------- + +/** + * The own board's pieces in deployment order — the order the battle deploys them (sim Battle.start; PRTS 卫戍协议/帮助 + * "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"): the operators by column from the left, top to bottom within a + * column (col asc, row desc, in board coordinates — so the mirrored right half of a boss prep also starts from its own + * left), then the summons the same way. A placed item (画卷) is not one of them. + * @param {any[]|null|undefined} board m.private board + * @returns {number[]} uids + */ +export function deployOrder(board) { + const rank = (p) => (p.kind === 'chess' ? 0 : 1); + return (Array.isArray(board) ? board : []) + .filter((p) => isObj(p) && Number.isInteger(p.uid) && Number.isInteger(p.row) && Number.isInteger(p.col) && (p.kind === 'chess' || p.kind === 'token')) + .sort((a, b) => rank(a) - rank(b) || a.col - b.col || b.row - a.row || a.uid - b.uid) + .map((p) => p.uid); +} + +/** + * Whether the round start's replay is due and which pieces play it (PR #416 by @Cromemadnd; the maintainer's confirmation + * from official play, 2026-10-13: at the start of every round the operators on the board replay their Start clip and + * landing sound one after another in deployment order). Due once per round, during 回合开始, on the viewer's own board — + * never a scouted or watched teammate's board, a spectator seat or an eliminated player — while it holds pieces. + * `replayed` is the round the screen last scheduled: the screen marks a round only once the field view took the sequence + * (render/app.js replayDeploys found the pieces), so a view or a board not built yet is asked again on the next render of + * the phase, and a reconnect or a re-render of the same round replays nothing. + * @param {{ phase?: string|null, round?: number|null, replayed?: number|null, ownBoard?: boolean, board?: any[]|null }} [s] + * @returns {{ round: number, uids: number[] } | null} + */ +export function roundStartReplay({ phase = null, round = null, replayed = null, ownBoard = false, board = null } = {}) { + if (phase !== PHASE.ROUND_START || !Number.isInteger(round) || round === replayed || !ownBoard) return null; + const uids = deployOrder(board); + return uids.length ? { round, uids } : null; +} + // ---- countdown ------------------------------------------------------------------------------------- /** diff --git a/test/render/deploy-replay.test.js b/test/render/deploy-replay.test.js new file mode 100644 index 00000000..2eb9e022 --- /dev/null +++ b/test/render/deploy-replay.test.js @@ -0,0 +1,203 @@ +// test/render/deploy-replay.test.js — PR #416 by @Cromemadnd, ported in 0.2.6 (DESIGN §31.8): a prep piece that lands — +// the round start's replay of the own board, a purchase, a move onto or across the board — plays its Start clip again +// with its landing sound (render/units.js UnitView.land → ctx.audio.deployCue), once per landing. Headless fake PIXI +// (test/render/fakepixi.js). The schedule itself (render/app.js replayDeploys, screens/game.js) runs in the browser check +// test/ui/deploy-replay.e2e.test.js; the order and the due rule are test/ui/deploy-replay.test.js. +// Run: node --test test/render/deploy-replay.test.js + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +let fake, UnitView, START_CLIP_FALLBACK; +before(async () => { + fake = installFakePixi(); + ({ UnitView, START_CLIP_FALLBACK } = await import('../../public/js/render/units.js')); +}); +after(() => fake.restore()); + +const cam = presetCamera('prep', { width: 1280, height: 720 }); +const ANIMS = ['Idle', 'Start', 'Die', 'Attack']; + +/** + * A prep (or battle) view of an operator whose model loads when `ready()` is called (`instant`: at once). `start` = the + * Start clip's length in the manifest entry (null: the model has no Start clip). Every deployCue is recorded. + */ +function rig({ prep = true, side = 'ally', kind = 'op', start = 1, instant = true, info = {} } = {}) { + let resolve = null; + const anims = { idle: 'Idle', deploy: start == null ? 'Idle' : 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } }; + const animations = { Idle: 1, Die: 1, Attack: 1, ...(start == null ? {} : { Start: start }) }; + const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims, animations }; + const data = { animations: ANIMS.filter((n) => start != null || n !== 'Start').map((name) => ({ name })) }; + const assets = { + spineEntry: () => entry, hasBack: () => false, + spine: { acquire: () => (instant ? Promise.resolve(data) : new Promise((r) => { resolve = () => r(data); })), release() {} }, + }; + const cues = []; + const audio = { deployCue: (u, key) => { cues.push({ ...u, key }); return true; } }; + const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam, audio }); + const v = new UnitView(ctx, { id: 'p:7', uid: 7, side, kind, defId: 'chess_char_x', spine: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1, dir: 'RIGHT', ...info }, { prep }); + const tick = async () => { await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r)); }; + return { v, cues, advance: (dt) => v.update(dt, cam, dt), ready: async () => { resolve?.(); await tick(); }, tick }; +} + +/** Run `fn` with performance.now() reading `t` ms (the landing window is real time). */ +function at(t, fn) { + const perf = globalThis.performance; + const now = perf.now; + perf.now = () => t; + try { return fn(); } finally { perf.now = now; } +} + +describe('UnitView.land — the Start clip again and the landing sound, once (PR #416)', () => { + test('an idle prep piece lands: Start plays and its landing sound is asked for once, with the piece as the key', async () => { + const h = rig(); + await h.tick(); + assert.ok(h.v.actor, 'the model is up'); + h.advance(0.5); + assert.equal(h.v.actor.mode, 'base'); + assert.equal(h.v.land(), true); + assert.equal(h.v.actor.mode, 'deploy', 'the Start clip plays'); + assert.equal(h.v.fadeIn, 0, 'and the piece fades in with it, like a deploy'); + assert.deepEqual(h.cues, [{ def: 'char_x', defId: 'chess_char_x', kind: 'op', key: 'p:7' }], 'the model id picks the sound, the kind its class'); + h.v.destroy(); + }); + + test('a second trigger of the same landing changes nothing and sounds nothing; after the clip a new landing plays again', async () => { + const h = rig(); + await h.tick(); + assert.equal(h.v.land(), true); // e.g. bought … + h.advance(0.4); + assert.equal(h.v.land(), false, '… then moved onto the board while its Start still plays'); + assert.equal(h.v.actor.mode, 'deploy'); + assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.4) < 1e-9, 'the clip is not restarted'); + assert.equal(h.cues.length, 1, 'one landing, one sound'); + // the round start's replay reaching a piece put down a moment before: the same + assert.equal(h.v.deploying(), true); + h.advance(0.7); // the 1 s clip is over + assert.equal(h.v.actor.mode, 'base'); + at(globalThis.performance.now() + 5000, () => { + assert.equal(h.v.deploying(), false); + assert.equal(h.v.land(), true, 'a later landing (another move) plays again'); + }); + assert.equal(h.cues.length, 2); + h.v.destroy(); + }); + + test('a landing asked before the model has loaded sounds at once and continues by the time gone (0.2.3); no second sound at load', async () => { + const h = rig({ instant: false }); + assert.equal(h.v.actor, null); + assert.equal(h.v.land(), true); + assert.equal(h.cues.length, 1, 'the sound belongs to the landing, not to the model'); + h.advance(0.3); + assert.equal(h.v.land(), false, 'still landing (owed to the model)'); + await h.ready(); + assert.equal(h.v.actor.mode, 'deploy'); + assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.3) < 1e-9, 'Start continues where the time says'); + assert.equal(h.cues.length, 1); + h.v.destroy(); + }); + + test('a model without a Start clip and a view without a model still land once per clip length', async () => { + const h = rig({ start: null }); + await h.tick(); + const t0 = 1000; + at(t0, () => assert.equal(h.v.land(), true)); + assert.equal(h.v.actor.mode, 'base', 'nothing to play'); + at(t0 + 500, () => assert.equal(h.v.land(), false, 'the landing still owns the clip length')); + at(t0 + START_CLIP_FALLBACK * 1000 + 1, () => assert.equal(h.v.land(), true)); + assert.equal(h.cues.length, 2); + h.v.destroy(); + // no manifest entry at all: the fallback length + const ctx = fakeViewCtx(fake.P, { assets: null, cam: () => cam, audio: { deployCue: () => true } }); + const bare = new UnitView(ctx, { id: 'p:8', uid: 8, side: 'ally', kind: 'op', defId: 'chess_char_y', x: 5, y: 10, maxHp: 1 }, { prep: true }); + assert.equal(bare._startDur(), START_CLIP_FALLBACK); + at(t0, () => assert.equal(bare.land(), true)); + at(t0 + 200, () => assert.equal(bare.land(), false)); + bare.destroy(); + }); + + test('never pulls a unit out of a battle, an attack, a death or a drag into the Start clip', async () => { + // a battle unit (the sim's own 'deploy' events go through onDeploy, which stays silent here) + const b = rig({ prep: false }); + await b.tick(); + b.advance(0.5); + assert.equal(b.v.land(), false); + assert.equal(b.v.actor.mode, 'base'); + b.v.onDeploy(); + assert.equal(b.v.actor.mode, 'deploy', 'a battle deploy still plays Start'); + assert.equal(b.cues.length, 0, 'and asks for no sound: audio.handleBattleEvents sounds battle deploys'); + b.v.destroy(); + // attacking, dying, dead, lifted, an enemy + const a = rig(); + await a.tick(); + a.advance(0.5); + a.v.onAttack(null, 1, 'none'); + assert.equal(a.v.actor.mode, 'attack'); + assert.equal(a.v.land(), false, 'attacking'); + a.v.die(); + assert.equal(a.v.land(), false, 'dying'); + a.v.destroy(); + assert.equal(a.v.land(), false, 'destroyed'); + const l = rig(); + await l.tick(); + l.v.lift = 0.3; + assert.equal(l.v.land(), false, 'lifted by a drag'); + l.v.lift = 0; + assert.equal(l.v.land(), true); + l.v.destroy(); + const e = rig({ side: 'enemy', kind: 'enemy' }); + await e.tick(); + assert.equal(e.v.land(), false, 'an enemy (the pen) never lands'); + e.v.destroy(); + assert.equal(a.cues.length + e.cues.length, 0); + // a hidden tab: nothing would show it, and the clip and the suspended audio would play it stale on return + const hid = rig(); + await hid.tick(); + globalThis.document.hidden = true; + try { assert.equal(hid.v.land(), false, 'hidden'); } finally { delete globalThis.document.hidden; } + assert.equal(hid.v.actor.mode, 'base'); + assert.equal(hid.cues.length, 0); + assert.equal(hid.v.land(), true, 'shown again'); + hid.v.destroy(); + }); + + test('a summon piece lands with its own class; a landing sound that throws never stops the clip', async () => { + const t = rig({ kind: 'token', info: { defId: 'token_10000_silent_healrb', spine: 'token_10000_silent_healrb' } }); + await t.tick(); + assert.equal(t.v.land(), true); + assert.deepEqual(t.cues[0], { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', key: 'p:7' }); + t.v.destroy(); + const h = rig(); + await h.tick(); + h.v.ctx.audio = { deployCue: () => { throw new Error('no audio'); } }; + assert.equal(h.v.land(), true); + assert.equal(h.v.actor.mode, 'deploy'); + h.v.ctx.audio = null; + h.v.destroy(); + }); +}); + +test('render/app.js wiring: landings in setPrep, the replay schedule, its timers cleared when prep mode ends and on destroy', () => { + const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); + // a purchase (an operator new on the bench), a merge's elite, a piece put on a board tile it was not on + assert.match(src, /if \(e\.piece\.kind === 'chess'\) landPiece\(v, 'buy'\);/); + assert.match(src, /landPiece\(v, 'merge'\);/); + assert.match(src, /if \(was && e\.area === 'board' && \(was\.area !== 'board' \|\| was\.row !== e\.row \|\| was\.col !== e\.col\)[^\n]*landPiece\(v, 'board'\);/); + assert.match(src, /const prevAt = new Map\(prepPieces\.map\(\(p\) => \[p\.uid, p\]\)\);/, 'compared with the last state of this prep, not a rebuild'); + // the replay: prep mode only, the same view, the tab shown, one turn after the other; cancelled by enterBattle, + // enterPrepMode and destroy + assert.match(src, /function replayDeploys\(uids, gap = DEPLOY_REPLAY_MS\) \{\s*if \(destroyed \|\| mode !== 'prep'/); + assert.match(src, /if \(destroyed \|\| mode !== 'prep'\) return;\s*const \{ uid, v \} = queue\[i\+\+\];\s*if \(!globalThis\.document\?\.hidden && views\.get\('p:' \+ uid\) === v\) landPiece\(v, 'round'\);\s*if \(i < queue\.length\) replayTimer = setTimeout\(turn, step\);/); + const body = (name) => src.slice(src.indexOf(name), src.indexOf(name) + 900); + assert.match(body('function enterBattle(meta)'), /cancelReplay\(\);/); + assert.match(body('function enterPrepMode()'), /cancelReplay\(\);/); + assert.match(body(' destroy() {\n if (destroyed) return;'), /cancelReplay\(\);/); + assert.match(src, /audio: opts\.audio \|\| null,/, 'the views reach the audio manager'); +}); diff --git a/test/render/playtest6-promote.test.js b/test/render/playtest6-promote.test.js index 745e4baf..0a88ab9f 100644 --- a/test/render/playtest6-promote.test.js +++ b/test/render/playtest6-promote.test.js @@ -83,6 +83,7 @@ test('render/app.js plays the promotion cue instead of the deploy flash for a me assert.match(src, /import \{ promotionsOf \} from '\.\/promote\.js';/); assert.match(src, /const before = prepPieces\.length \? prepPieces : promoBase;[\s\S]{0,80}promotionsOf\(before, list, \(id\) => data\.chess\(id\)\)/, 'compared with the previous state'); assert.match(src, /if \(prepPieces\.length\) promoBase = prepPieces;\s*prepPieces = \[\];/, 'a battle keeps the last prep state: a merge between two preps is cued too (QA 6b)'); - assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ v\.onDeploy\?\.\(\); fx\.deploy\(v\); \}/); + // (the elite and a new board piece land — the Start clip and its landing sound, PR #416 — and only the latter flashes) + assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ landPiece\(v, 'board'\); fx\.deploy\(v\); \}/); assert.match(src, /promotions,/, 'the cue log is exposed on view.debug (E2E)'); }); diff --git a/test/ui/deploy-replay.e2e.test.js b/test/ui/deploy-replay.e2e.test.js new file mode 100644 index 00000000..82322baa --- /dev/null +++ b/test/ui/deploy-replay.e2e.test.js @@ -0,0 +1,142 @@ +// Browser check of the round start's deploy replay (PR #416 by @Cromemadnd, DESIGN §31.8) on the mock harness +// (public/dev/game-mock.html) in headless Chrome (puppeteer-core + the system Chrome); opt-in, ~15 s: +// SP_E2E=1 node --test test/ui/deploy-replay.e2e.test.js +// +// From a settled battle to 回合开始 of the next round, the real game screen (screens/game.js) and Pixi field view +// (render/app.js) replay the own board's pieces one after another in deployment order — operators by column from the +// left, top to bottom within a column (sim Battle.start) — each with one landing sound (audio.deployCue, spied); the +// same round never again (a re-pushed state, the 休整期 that follows), the next round once more. In the 休整期 a purchase +// lands the bought operator; a move onto the board while that Start still plays sounds nothing more; a later move to +// another tile lands again; turning in place does not. No console / page errors. + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; + +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** The deployment order, written out independently of ui/gameLogic deployOrder: operators, then summons; col asc, row desc. */ +const expectedOrder = (board) => board.filter((p) => p && (p.kind === 'chess' || p.kind === 'token')) + .sort((a, b) => (a.kind === 'chess' ? 0 : 1) - (b.kind === 'chess' ? 0 : 1) || a.col - b.col || b.row - a.row) + .map((p) => p.uid); + +describe('the round start replays the own board\'s Start clips in deployment order', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => { + let srv; + let browser; + let base; + before(async () => { + const { startServer } = await import('../../server/index.js'); + const puppeteer = (await import('puppeteer-core')).default; + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + await srv.assetIndex?.whenReady(); + base = `http://127.0.0.1:${srv.port}`; + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + test('SETTLE → 回合开始: one landing per piece, in order and 50 ms apart; once per round; purchases and moves land once', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 1600, height: 900 }); + const problems = []; + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + await page.goto(`${base}/dev/game-mock.html?shot=1&render=engine&phase=SETTLE`, { waitUntil: 'networkidle0', timeout: 60000 }); + await page.waitForFunction(() => globalThis.__SP_VIEW__?.kind === 'engine' && globalThis.__SP_VIEW__.raw.mode === 'battle', { timeout: 20000 }); + // spy on the landing sound (the view's ctx.audio is this very manager) and stop the mock battle's stream + await page.evaluate(async () => { + const { audio } = await import('/js/audio.js'); + const cues = globalThis.__cues = []; + const real = audio.deployCue.bind(audio); + audio.deployCue = (info, key) => { cues.push({ key, at: performance.now() }); return real(info, key); }; + }); + const landings = (why) => page.evaluate((w) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => !w || l.why === w).map((l) => ({ ...l })), why); + const toPhase = (phase, round) => page.evaluate((ph, r) => globalThis.__MOCK__.mutate((S) => { + if (S.battle?.timer) clearInterval(S.battle.timer); + S.battle = null; + S.pub.phase = ph; S.pub.round = r; S.pub.deadline = Date.now() + (ph === 'ROUND_START' ? 2000 : 60000); + S.priv.ready = false; + }), phase, round); + + // ---- 回合开始 of round 7: the board replays in deployment order + const board = await page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv.board))); + const order = expectedOrder(board); + assert.ok(order.length >= 5, `the mock board has pieces (${order.length})`); + const t0 = await page.evaluate(() => performance.now()); + await toPhase('ROUND_START', 7); + await page.waitForFunction((n) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => l.why === 'round').length >= n, { timeout: 5000 }, order.length); + // the last piece's Start is playing right now (on its model, or owed to one still loading) + const lastPlaying = await page.evaluate((uid) => globalThis.__SP_VIEW__.raw.debug.views.get(`p:${uid}`)?.deploying(), order.at(-1)); + const round7 = await landings('round'); + assert.deepEqual(round7.map((l) => l.uid), order, 'every operator once, in deployment order (by column from the left, top to bottom)'); + assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.mode), 'prep'); + assert.ok(round7[0].at >= t0, 'scheduled by this round start'); + for (let i = 1; i < round7.length; i++) { + const gap = round7[i].at - round7[i - 1].at; + assert.ok(gap >= 35 && gap < 400, `one after another, ~50 ms apart (${gap.toFixed(1)} ms between #${i - 1} and #${i})`); + } + assert.equal(lastPlaying, true, 'the piece is in its Start clip when it lands'); + const cues7 = await page.evaluate(() => globalThis.__cues.map((c) => c.key)); + assert.deepEqual(cues7, order.map((uid) => `p:${uid}`), 'one landing sound per piece, in the same order'); + + // ---- the same round again: a re-pushed state (a reconnect's resync, a re-render) and the 休整期 that follows replay nothing + await page.evaluate(() => { globalThis.__MOCK__.pushPublic(); globalThis.__MOCK__.mutate(() => {}); }); + await sleep(600); + await toPhase('PREP', 7); + await sleep(800); + assert.equal((await landings('round')).length, order.length, 'round 7 replayed once'); + assert.equal(await page.evaluate(() => globalThis.__cues.length), order.length, 'and sounded once per piece'); + + // ---- a purchase lands the bought operator; a move onto the board during its Start sounds nothing more + const slot = await page.evaluate(() => globalThis.__MOCK__.S().priv.shop.slots.findIndex((s) => s && s.kind === 'chess' && !s.sold)); + assert.ok(slot >= 0, 'the shop offers an operator'); + await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; })); + await page.evaluate(async (i) => { const { net } = await import('/js/net.js'); await net.request('g.buy', { slot: i }); }, slot); + await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.debug.landings.some((l) => l.why === 'buy'), { timeout: 4000 }); + const bought = (await landings('buy')).at(-1).uid; + const inHand = await page.evaluate((uid) => globalThis.__MOCK__.S().priv.hand.some((p) => p && p.uid === uid), bought); + assert.ok(inHand, 'the landing is the bought piece on the bench'); + const free = await page.evaluate(() => { + const S = globalThis.__MOCK__.S(); + const used = new Set(S.priv.board.map((p) => `${p.row},${p.col}`)); + const dt = S.stage.deployTiles.normal; + return [...dt.melee, ...dt.rangedOnly].filter(([r, c]) => !used.has(`${r},${c}`)).slice(0, 2); + }); + assert.equal(free.length, 2, 'two free deploy tiles'); + const move = (uid, [row, col], dir = 'RIGHT') => page.evaluate(async (u, r, c, d) => { + const { net } = await import('/js/net.js'); + await net.request('g.move', { uid: u, to: { area: 'board', row: r, col: c }, dir: d }); + }, uid, row, col, dir); + const cuesOf = (uid) => page.evaluate((k) => globalThis.__cues.filter((c) => c.key === k).length, `p:${uid}`); + await move(bought, free[0]); + await sleep(300); + assert.equal(await page.evaluate((uid) => globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), bought), true, 'it is on the board'); + assert.equal(await cuesOf(bought), 1, 'bought and moved within one Start clip: one landing sound'); + // a later move to another tile lands again; turning in place does not + await sleep(1400); + await move(bought, free[1]); + await page.waitForFunction((uid) => globalThis.__SP_VIEW__.raw.debug.landings.some((l) => l.why === 'board' && l.uid === uid), { timeout: 4000 }, bought); + assert.equal(await cuesOf(bought), 2, 'a move to another tile is a new landing'); + await sleep(1400); + await move(bought, free[1], 'UP'); + await sleep(600); + assert.equal(await cuesOf(bought), 2, 'turning in place is no landing'); + assert.equal((await landings('round')).length, order.length, 'the 休整期 never replays the round start'); + + // ---- the next round replays again (with the bought operator in its place in the order) + await toPhase('SETTLE', 7); + await sleep(300); + const board8 = await page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv.board))); + await toPhase('ROUND_START', 8); + const order8 = expectedOrder(board8); + await page.waitForFunction((n) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => l.why === 'round').length >= n, { timeout: 5000 }, order.length + order8.length); + const round8 = (await landings('round')).slice(order.length).map((l) => l.uid); + assert.deepEqual(round8, order8, 'round 8 replays its board in deployment order'); + await page.close(); + assert.deepEqual(problems, []); + }); +}); diff --git a/test/ui/deploy-replay.test.js b/test/ui/deploy-replay.test.js new file mode 100644 index 00000000..bab02d54 --- /dev/null +++ b/test/ui/deploy-replay.test.js @@ -0,0 +1,130 @@ +// test/ui/deploy-replay.test.js — PR #416 by @Cromemadnd, ported in 0.2.6 (DESIGN §31.8): the round start replays the own +// board's Start clips in deployment order (ui/gameLogic deployOrder / roundStartReplay — the maintainer's confirmation from +// official play, 2026-10-13), and a landing sounds like the piece's battle deployment through the SFX channel +// (public/js/audio.js deployCue). The view side is test/render/deploy-replay.test.js, the browser check +// test/ui/deploy-replay.e2e.test.js. +// Run: node --test test/ui/deploy-replay.test.js + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { deployOrder, roundStartReplay } from '../../public/js/ui/gameLogic.js'; +import { AudioManager, deploySfxUrl } from '../../public/js/audio.js'; +import { PHASE } from '../../shared/constants.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8')); +const chess = (uid, row, col, extra = {}) => ({ uid, kind: 'chess', id: `chess_${uid}`, row, col, ...extra }); + +describe('deployOrder — the order the battle deploys the board (Battle.start)', () => { + test('operators by column from the left, top to bottom within a column, then the summons the same way; no items', () => { + // board rows 9 (bottom) … 12 (top), cols 2 … 10; given in reading order, as m.private sends it (server board.js boardOrder) + const board = [ + chess(1, 12, 5), chess(2, 12, 2), { uid: 3, kind: 'token', id: 'token_a', row: 12, col: 3 }, chess(4, 11, 2), + { uid: 5, kind: 'item', id: 'chess_item_6_03_m', row: 10, col: 4 }, chess(6, 10, 5), chess(7, 9, 3), + { uid: 8, kind: 'token', id: 'token_b', row: 9, col: 2 }, + ]; + assert.deepEqual(deployOrder(board), [2, 4, 7, 1, 6, 8, 3]); + // the same rule the client's merge target uses (gameLogic mergeTarget: col asc, then row desc) + assert.deepEqual(deployOrder([chess(1, 9, 4), chess(2, 12, 4), chess(3, 10, 3)]), [3, 2, 1]); + }); + + test('junk and missing fields are left out; an empty or absent board is no order', () => { + assert.deepEqual(deployOrder(null), []); + assert.deepEqual(deployOrder([]), []); + assert.deepEqual(deployOrder([null, 'x', { uid: 1, kind: 'chess', row: 10 }, { kind: 'chess', row: 10, col: 3 }, chess(9, 10, 3)]), [9]); + }); +}); + +describe('roundStartReplay — once per round, in 回合开始, on the own board', () => { + const board = [chess(11, 12, 6), chess(12, 9, 2)]; + const base = { phase: PHASE.ROUND_START, round: 7, replayed: 6, ownBoard: true, board }; + + test('due in 回合开始 of a round not replayed yet: its pieces in deployment order', () => { + assert.deepEqual(roundStartReplay(base), { round: 7, uids: [12, 11] }); + }); + + test('not again for the same round (a reconnect, a re-render); not in another phase', () => { + assert.equal(roundStartReplay({ ...base, replayed: 7 }), null); + for (const phase of [PHASE.PREP, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.COMBAT, PHASE.BATTLE_CHECK, null]) { + assert.equal(roundStartReplay({ ...base, phase }), null, String(phase)); + } + assert.equal(roundStartReplay({ ...base, round: null }), null); + }); + + test('only the viewer\'s own board: a scouted teammate, a spectator seat or an eliminated player gets none', () => { + assert.equal(roundStartReplay({ ...base, ownBoard: false }), null); + }); + + test('an empty board (or a private state not here yet) schedules nothing — and marks nothing, so a board that arrives later in 回合开始 still plays', () => { + assert.equal(roundStartReplay({ ...base, board: [] }), null); + assert.equal(roundStartReplay({ ...base, board: undefined }), null); + assert.deepEqual(roundStartReplay({ ...base, replayed: null }), { round: 7, uids: [12, 11] }); + }); + + test('screens/game.js asks after the wiring effect and marks the round only once the view took the sequence', () => { + const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + const wiring = src.indexOf('}, [view, showPrep, priv, editable, field, combat, mode, watchingOther, watching, holdSeq]);'); + const replay = src.indexOf('const plan = roundStartReplay('); + assert.ok(wiring > 0 && replay > wiring, 'the replay effect runs after setPrep in the same commit'); + assert.match(src, /roundStartReplay\(\{ phase, round: pub\?\.round, replayed: replayedRef\.current, ownBoard: showPrep && alive && !spectator, board: priv\?\.board \}\)/); + assert.match(src, /if \(plan && view && view\.replayDeploys\?\.\(plan\.uids\) > 0\) replayedRef\.current = plan\.round;/); + assert.doesNotMatch(src, /deployPiece|deploySequence/, 'no landing at the wheel\'s confirm: the state that shows the move lands the piece once'); + const host = readFileSync(path.join(ROOT, 'public/js/ui/fieldHost.js'), 'utf8'); + assert.match(host, /'stripesUnder', 'replayDeploys'\]/, 'an optional view method (the simplified board has none)'); + }); +}); + +describe('AudioManager.deployCue — the landing sound', () => { + const OWN = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && typeof manifest.audio.sfx.units[k].born === 'string'); + const PLAIN = Object.keys(manifest.chars).find((k) => !manifest.audio.sfx.units[k]?.born); + const rig = (o = {}) => { + const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => 0, ...o }); + a.ctx = {}; // unlocked + const played = []; + a._play = (url, opt) => { played.push({ url, ...opt }); }; + return { a, played }; + }; + + test('the sound of the piece\'s battle deployment: its own ON_UNIT_BORN file, else b_char_set / b_char_tokenset, limited per piece', () => { + assert.ok(OWN && PLAIN, 'the manifest has operators with and without an own deploy sound'); + const { a, played } = rig(); + assert.equal(a.deployCue({ def: OWN, defId: 'chess_x', kind: 'op' }, 'p:3'), true); + assert.equal(a.deployCue({ def: PLAIN, defId: 'chess_y', kind: 'op' }, 'p:4'), true); + assert.equal(a.deployCue({ def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' }, 'p:5'), true); + assert.deepEqual(played.map((p) => p.url), [ + manifest.audio.sfx.units[OWN].born, manifest.audio.sfx.battle.deploy, + deploySfxUrl(manifest, { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', side: 'ally' }), + ]); + assert.equal(played[2].url, manifest.audio.sfx.units.token_10000_silent_healrb?.born ?? manifest.audio.sfx.battle.tokenDeploy, 'a summon: the token sound'); + assert.deepEqual(played.map((p) => [p.limited, p.unitKey]), [[true, 'land:p:3'], [true, 'land:p:4'], [true, 'land:p:5']], 'the battle limiter: ≤ 2 copies of one sound, a cooldown per piece'); + assert.equal(played[1].volume, 0.5, 'the generic file at the battle deploy\'s gain'); + assert.ok(played[0].volume > 0 && played[0].volume <= 0.8, 'an own file at its bank gain'); + }); + + test('a piece without a sound, an enemy model, no manifest: nothing, never a throw', () => { + const { a, played } = rig({ getManifest: () => ({ audio: { sfx: { battle: {}, units: {} } } }) }); + assert.equal(a.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + const none = rig({ getManifest: () => null }); + assert.equal(none.a.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + assert.equal(none.a.deployCue(null, 1), false); + assert.equal(played.length + none.played.length, 0); + }); + + test('the SFX volume and mute apply: muted or at 0 nothing is even fetched', () => { + const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => 0 }); + a.ctx = {}; + const fetched = []; + a._buffer = (url) => { fetched.push(url); return Promise.resolve(null); }; + a.setVolumes({ sfx: 0.8, muted: true }); + a.deployCue({ def: PLAIN, kind: 'op' }, 'p:1'); + a.setVolumes({ sfx: 0, muted: false }); + a.deployCue({ def: PLAIN, kind: 'op' }, 'p:2'); + assert.deepEqual(fetched, []); + a.setVolumes({ sfx: 0.8, muted: false }); + a.deployCue({ def: PLAIN, kind: 'op' }, 'p:3'); + assert.deepEqual(fetched, [manifest.audio.sfx.battle.deploy]); + }); +});