diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b23a4fe..d3a21e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### 界面 - 官方战场场景的光照改回提取出的官方值:直射光和烘焙光不再被压暗,场景不再明显偏暗(#549,PR #554,感谢 @xcdoge)。 - 最后一个还没选策略的玩家不再看到可点的「跳过」按钮:已经选完的玩家仍显示在顺序里,但不算可以让过去的人(PR #546,感谢 @Xinglan233)。 +- 每回合开始时,自己棋盘上的干员按部署顺序一个接一个重播入场动画并播放落地音效(维护者按官方实机确认);买到干员、干员上场或换到另一格时也会播放一次,同一次落地只响一声,音量跟随「音效」(PR #416,感谢 @Cromemadnd)。 +- 设置新增「弹药数量」,是可选项、默认关闭(官方界面没有这个数字):打开后弹药类技能的黄色弹药条下方显示剩余弹药 / 总弹药数(如 13/14),格子密到画不出分隔线时也能读出(PR #552,感谢 @IceCodeNew)。 ### 工具与开发 - Windows 下场景素材测试的仓库路径不再重复盘符(PR #547,感谢 @Xinglan233)。 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f25892df..29e2e6af 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 只狼影出现。 @@ -86,7 +87,7 @@ - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。被推开、拉动的敌人会进入官方的**失衡**状态,这段时间里不走路、也不发动普通攻击:推开按力度减重量的档位持续 0.2–约 1.17 秒(中等力度推重量 1 的敌人为 0.8 秒),拉拽持续 1 秒(力度比重量低 2 档时 0.5 秒,拉到身前后也一样),失衡期间也不会放技能(例如弑君者的闪现)或执行脚本移动(例如枯朽之种俯冲),弧光锋卫则在失衡期间每 0.066 秒受到 400 点真实伤害;推不动的空中单位(静态刚体)被推时停顿 0.1 秒、被拉时停顿整个拉力作用时间;浮空、重生和路线上的瞬移出现都会立刻解除失衡。画面上的滑行也用同样的时间(默认 2 倍速下现实时间减半)。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人和引星棘刺(一、二技能)的炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 -- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。 +- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。官方界面不显示剩余发数;想看数字可以打开「设置」里的「弹药数量」(默认关闭,0.2.6,PR #552),弹药条下方就会显示剩余弹药 / 总弹药数(如 13/14,包括额外弹药和补弹),弹匣太多、格子密到画不出分隔线时也能读出来。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。 - **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。 @@ -178,6 +179,8 @@ **伤害数字**:标题画面、大厅、房间和对局里的「设置」有「显示伤害数字」(默认打开,每一次命中和治疗都跳出数字)和「官方伤害数字」(默认关闭)。打开「官方伤害数字」后,只在伤害达到 [PRTS 作战机制](https://prts.wiki/w/作战机制)「伤害红字(暴击)」的条件时显示红色整数(最终伤害不低于期望伤害的 1.5 倍;闪避了本应显示红字的一击时显示黄色 MISS)。关掉「显示伤害数字」则什么数字都不显示。无敌时官方客户端会显示红色 0,本作目前不会为这种未击中补一条伤害事件,因此这种红色 0 暂时看不到。 +**弹药数量**:「设置」里的「弹药数量」默认关闭(官方界面没有这个数字);打开后,弹药类技能开启时弹药条下方显示剩余弹药 / 总弹药数,技能结束、休整期和干员倒下时不显示。简化棋盘没有弹药条,也不显示这个数字。 + **修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 ## 12. 反馈问题 diff --git a/docs/design/client.md b/docs/design/client.md index 65472eb5..45f9f0c8 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 @@ -42,7 +44,7 @@ view.resize() ; view.destroy() Asset URLs (0.2.6, host bandwidth layer 1): the client takes every art, model and audio URL from `data/assets.json` and `data/local-assets.json` and builds none itself, so the content-hash segment the server puts in front (`/assets/~/…`, docs/ASSETS.md "The committed manifest and the served manifests") needs nothing on this side: `validSpine` still sees `.skel`, pixi-spine finds the atlas and its pages under the same segment (a model shares one hash), audio is fetched as `/media/~/…`. The two URLs the client derives from another file's — the 3D board's `tiles.json` beside the atlas and the token death sound beside `charDie` — are served `no-cache` (the board) or drop the segment (`shared/assetPaths.js unhashedUrl`, the sound). -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`; with 设置「弹药数量」 on, its count `left/magazine` under it, §31.9), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- @@ -50,7 +52,7 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels. -Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms; a 设置 button left of 玩法说明 opens the title screen's settings dialog, GitHub #238), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start; the same 设置 button), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule (the kill counter reads 已解决 / the round's own enemies: a leak counts, a split child or summon is in neither number, §14)/LP (live during the own battle, §17.5)/timer/ready, and beside the latency a 观战席 capsule (eye + count, a roster with the host's ✕) while the room has spectators (GitHub #120); bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards — a slot the upgrade just opened is a bare frame until the next refresh, GitHub #332 —, item card, funds, refresh, freeze, collapse; 准备就绪 folds it and cancelling unfolds it, GitHub #138); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). +Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms; a 设置 button left of 玩法说明 opens the title screen's settings dialog, GitHub #238), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start; the same 设置 button), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule (the kill counter reads 已解决 / the round's own enemies: a leak counts, a split child or summon is in neither number, §14)/LP (live during the own battle, §17.5)/timer/ready, and beside the latency a 观战席 capsule (eye + count, a roster with the host's ✕) while the room has spectators (GitHub #120); bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards — a slot the upgrade just opened is a bare frame until the next refresh, GitHub #332 —, item card, funds, refresh, freeze, collapse; 准备就绪 folds it and cancelling unfolds it, GitHub #138); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers, the optional ammo count)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). 盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。 @@ -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, in landing voices of its own that take over the oldest, §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,35 @@ 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 and mute, in landing voices of its own +(`LAND_VOICES` 3 at once [ASSUMED], a 160 ms cooldown per piece, not the battle limiter): a landing over the cap takes the +oldest one's voice (a 30 ms fade) instead of being dropped, so every landing of a round start is heard though most share +the 1.41 s b_char_set; the field view fetches the generic files ahead (`warmLandings`). 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. + +### Optional ammo count (§31.9) + +设置 →「弹药数量」 (`ammoCount`, PR #552 by @IceCodeNew) defaults off — the official HUD has no such number (the +maintainer's decision of 2026-10-13). On, an operator's ammo bar carries `left/magazine` under it (`UnitView._updateAmmoCount`, +the exact b.snap `ammo` counts, extra rounds and refills included), centred in the bar's yellow; the rows under the bars +move down by its height. It hides with the magazine, in the prep view and after death. The simplified board has no ammo +bar and so no count. diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 054d8808..12c977d9 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -117,3 +117,103 @@ 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, + in landing voices of their own (`AudioManager._playLanding`: `LAND_VOICES` 3 at once, a 160 ms cooldown per piece; the + battle limiter is not used). A landing over the cap takes the oldest landing's voice, whose copy fades out over 30 ms, + instead of being dropped: 192 of the 210 operators land with the same 1.41 s b_char_set and the round start lands one + every 50 ms, so the battle rule (≤ 2 copies of a file, the rest dropped) left all but the first two landings of a chain + silent (Grok's review of this port). With 3 voices every copy rings 150 ms before it is taken over — b_char_set's + attack, its loudest part, lies in its first 100 ms, and b_char_tokenset has 95 % of its energy in 150 ms — while the + summed level stays one copy above the battle's two. The field view asks for the two generic files when a match screen + comes up (`warmLandings`; kept until the first gesture or until the SFX are audible), so a round start's first landings + never wait for a download. 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 sounded most + battle deployments a second time). 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 landed a piece at the + wheel's confirm instead, restarting any Start that had run 0.3 s (a purchase followed by a move sounded twice); its + round-start sequence forced every piece, one that had just landed included, and it landed 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, a turn in place included. 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). The 3 landing voices taken over oldest first (the official born bank allows any +number of copies; three keep every landing's attack within a bounded level). 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` +on the real AudioManager, SfxLimiter and buffer cache with a fake Web Audio context: 8 b_char_set landings 50 ms apart start +8 times, at most 3 at once, the oldest taken over each time; a 36-piece board within the cap; the per-piece cooldown; a +landing taken over while it downloads never starts; the warm-up; 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 that really starts in the page's audio, at +most 3 landing voices at once, 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). + +### 31.9 Optional ammo count (PR #552) + +PR #552 by @IceCodeNew. + +**Seen.** While an ammo skill runs, the SP bar is the yellow magazine (one cell per round, 0.2.2) with no number. A +magazine whose cells are under 2 px draws no cuts between them (`AMMO_BAR.minCell`), so a large one (240 rounds) cannot be +counted. The PR draws `remaining/capacity` under the bar. + +**Decision.** The official client shows no such number. The maintainer's decision of 2026-10-13: an optional setting, off +by default, like 「官方伤害数字」 (§30.20). + +**Now.** 设置 →「弹药数量」 (`ammoCount`) is sanitised with the other settings: a missing or non-boolean saved value is off. +Its hint says the official interface has no such number. The field view takes the value at creation and live +(`setSettings`). `UnitView._updateAmmoCount` draws `left/magazine` from the exact b.snap `ammo` counts (拉特兰's extra +rounds and refills included), centred under the ammo bar in its yellow at 10–16 px by the zoom, and moves the rows under +the bars (狼影 pips, the purse, the element gauge) down by its height. It hides with the magazine (the skill's end, an +older snapshot without the row), in the prep view, after death and for enemies; while the option is off nothing is built. +The simplified 2D board has no ammo bar (its SP bar reads the tuple's SP), so it shows no count. The diagnostics' settings +line names `ammo count` when it is on. Display only: the sim, the protocol and the data are unchanged. The PR's last commit +inlined the readout into `_updateHud`; here it is a method like the purse's (`_updateCoins`), behind the setting. + +**[ASSUMED]** Nothing new: the font, size and place are the PR's. + +**Tests.** `test/render/hud-readouts.test.js` (off: nothing drawn and the rows below unmoved; on: the exact count through +spending, refills, capacity changes and dense magazines, hidden at the skill's end, in the prep view, after death and for +enemies, the live toggle), `test/ui/ammo-count.test.js` (normalisation, the toggle and its hint in every language pack, the +view's live setting, the diagnostics line), `test/ui/gameLogic.test.js` (sanitizeSettings). Golden: unchanged. diff --git a/public/i18n/en.json b/public/i18n/en.json index 96e2cc0a..85fef76a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -158,6 +158,8 @@ "显示伤害数字": "Damage Numbers", "官方伤害数字": "Official Damage Numbers", "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "Show only red numbers that meet the official crit rule. While off, every hit still shows a number.", + "弹药数量": "Ammo Count", + "弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。": "While an ammo skill runs, shows the rounds left / in total under its ammo bar (e.g. 13/14). The official interface has no such number.", "画面质量": "Graphics Quality", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction", "快捷键": "Hotkeys", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 2b16ce51..9dd7294c 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -164,6 +164,8 @@ "显示伤害数字": "ダメージ数値を表示", "官方伤害数字": "公式ダメージ数値", "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "公式のクリティカル条件を満たす赤い数値だけを表示します。オフのときは、これまで通り命中ごとに表示します。", + "弹药数量": "弾薬数", + "弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。": "弾薬スキルの発動中、弾薬バーの下に残弾数/総弾数(例:13/14)を表示します。公式の画面にはない表示です。", "画面质量": "画質", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "タッチ操作:ユニットをタップで選択(撤退 / 売却)· ユニットやカードを長押しで詳細表示 · ドラッグで配置後、スワイプで向きを選択", "快捷键": "ショートカットキー", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 2d437f67..4c31399f 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -160,6 +160,8 @@ "显示伤害数字": "대미지 수치 표시", "官方伤害数字": "공식 대미지 수치", "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "공식 치명타 조건을 만족하는 빨간 숫자만 표시합니다. 끄면 지금까지처럼 모든 타격에 숫자가 표시됩니다.", + "弹药数量": "탄약 수", + "弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。": "탄약 스킬이 켜져 있는 동안 탄약 바 아래에 남은 탄약 / 전체 탄약 수(예: 13/14)를 표시합니다. 공식 화면에는 없는 숫자입니다.", "画面质量": "그래픽 품질", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "터치 조작: 유닛을 탭하여 선택(퇴각 / 판매) · 유닛이나 카드를 길게 눌러 상세 정보 확인 · 드래그로 배치한 뒤 스와이프로 방향 선택", "快捷键": "단축키", diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 26ea8dfa..bc763978 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -162,6 +162,8 @@ "显示伤害数字": "顯示傷害數字", "官方伤害数字": "官方傷害數字", "只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "只顯示達到官方暴擊條件的紅色數字。關閉時仍顯示每一次命中。", + "弹药数量": "彈藥數量", + "弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。": "彈藥類技能開啟時,在彈藥條下方顯示剩餘彈藥 / 總彈藥數(如 13/14)。官方介面沒有這個數字。", "画面质量": "畫面品質", "触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "觸控操作:點擊單位選中(撤退 / 出售)· 長按單位或卡牌查看詳情 · 拖動部署後滑動選擇朝向", "快捷键": "快捷鍵", diff --git a/public/js/audio.js b/public/js/audio.js index 6feeaa7b..902aa2be 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -62,6 +62,10 @@ // - 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 through the SFX channel's volume and mute, in its own voices (LAND_VOICES) instead of the battle limiter — +// a full pool hands the oldest landing's voice to the newest, so every piece of the chain is heard. // - 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()`, @@ -101,6 +105,19 @@ const IMPACT_TYPES = new Set(['phys', 'arts', 'true']); const IMPACT_WINDOW_MS = 2500; /** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */ const PENDING_SKILL_MAX = 64; +/** + * Prep landing sounds (deployCue, PR #416) at once: their own pool, apart from the battle limiter. A landing over the cap + * takes the oldest landing's voice (it fades out in LAND_STEAL_S) instead of being dropped — 192 of the 210 operators + * land with the same 1.41 s b_char_set and the round start lands one every 50 ms, so the battle rule (≤ 2 copies of a + * file, the rest dropped) left all but the first two silent. 3: at the replay's 50 ms rhythm every copy still rings for + * 150 ms before it is taken over — b_char_set's attack, its loudest part, lies in its first 100 ms, and b_char_tokenset + * (0.5 s) has 95 % of its energy in 150 ms — while three copies sum at most one more than the battle's two; a fourth would + * add ~2.5 dB of level to a dense chain and no further attack. [ASSUMED] (no source gives the official cap: its born bank + * allows any number of copies). + */ +export const LAND_VOICES = 3; +/** Seconds a landing sound taken over by a newer one fades out (a hard stop clicks). */ +const LAND_STEAL_S = 0.03; /** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i; @@ -430,6 +447,18 @@ export class SfxLimiter { const n = this.activeByUrl.get(url) || 0; if (n <= 1) this.activeByUrl.delete(url); else this.activeByUrl.set(url, n - 1); } + + /** + * Whether `unitKey` is still inside its cooldown at `now` (ms) — read only; a caller that takes a voice by stealing + * (AudioManager._playLanding) asks first, so a refused key never costs another sound its voice. + * @param {number} now ms + * @param {string|number|null} unitKey + */ + cooling(now, unitKey) { + if (unitKey == null) return false; + const t = this.lastByUnit.get(unitKey); + return t != null && now - t < this.unitCooldownMs; + } } /** @@ -528,6 +557,12 @@ export class AudioManager { this.bufBytes = new Map(); // url → decoded PCM bytes (the byte budget of the LRU, see _buffer) this.warned = new Set(); this.limiter = new SfxLimiter(); + // prep landings (deployCue): their own voices — a full pool hands the oldest one's voice to the newest (_playLanding); + // no per-file gap (one landing per piece, the cap bounds the overlap), the per-piece cooldown of the battle sounds + this.landLimiter = new SfxLimiter({ maxVoices: LAND_VOICES, maxPerUrl: LAND_VOICES, urlGapMs: 0 }); + this.landVoices = []; // the landing sounds holding a voice, oldest first: { url, src, gain, over, release } + this.landStats = { started: 0, stolen: 0 }; // landing sounds started / taken over since the page opened (dev / tests) + this.landWarm = false; // the field view asked for the landing sounds ahead while they could not be fetched (warmLandings) this.voiceGate = new VoiceGate(); this.voiceNode = null; // { src, gain, url, token } of the line on air this.voiceToken = 0; @@ -606,6 +641,7 @@ export class AudioManager { } catch { /* ignore */ } this._applyVolumes(); this._primeOutput(); + if (this.landWarm) this.warmLandings(); if (this.ctx.state === 'running') this._dropUnlock(); else { const p = this.ctx.resume?.(); @@ -679,6 +715,7 @@ export class AudioManager { if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue(); // the music became audible again: download and start the track the screen wants now (none was fetched meanwhile) if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); } + if (this.landWarm && this.ctx && !this.volumes.muted && this.volumes.sfx > 0) this.warmLandings(); } /** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */ @@ -1127,6 +1164,104 @@ 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 landing voices (_playLanding: at most LAND_VOICES at once, the oldest + * taken over by the newest, a cooldown per piece) — every landing is heard, one after another. + * @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._playLanding(url, `land:${key}`, own ? unitGain(0.8, mix) : 0.5); + return true; + } catch { return false; } + } + + /** + * Play a landing sound in the landing voices: never dropped for want of a voice — over LAND_VOICES (any file) the oldest + * landing, playing or still loading, gives its voice up (_stealLanding) and the new one starts; only the per-piece + * cooldown refuses one (the same piece twice within UNIT_COOLDOWN_MS: one landing never sounds twice), checked before + * anything is taken over. The battle limiter is not touched. Muted or at SFX volume 0, nothing happens. + * @param {string} url + * @param {string} unitKey the piece's cooldown key (`land:`) + * @param {number} volume gain of this sound under the SFX channel + */ + _playLanding(url, unitKey, volume) { + if (!this.ctx || typeof url !== 'string' || !url || this.volumes.muted || this.volumes.sfx <= 0) return; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const pool = this.landLimiter; + if (pool.cooling(now, unitKey)) return; + while (!pool.tryAcquire(now, unitKey, url)) { + if (!this.landVoices.length) return; // (a full pool always holds voices; never loop on a refusal it cannot lift) + this._stealLanding(this.landVoices[0]); + } + const v = { url, src: null, gain: null, over: false, release: null }; + v.release = () => { + if (v.over) return; + v.over = true; + pool.release(url); + const i = this.landVoices.indexOf(v); + if (i >= 0) this.landVoices.splice(i, 1); + }; + this.landVoices.push(v); + this._buffer(url).then((buf) => { + if (v.over || !buf || !this.ctx) { v.release(); return; } // taken over while it loaded: it never starts + try { + const s = this.ctx.createBufferSource(); + s.buffer = buf; + const g = this.ctx.createGain(); + g.gain.value = Math.max(0, Math.min(1.5, volume)); + s.connect(g); g.connect(this.sfxGain); + v.src = s; v.gain = g; + let done = false; + const end = () => { if (done) return; done = true; v.release(); try { g.disconnect(); } catch { /* ignore */ } }; + s.onended = end; + setTimeout(end, (buf.duration || 0) * 1000 + 250); // safety if onended never fires + s.start(); + this.landStats.started += 1; + } catch { v.release(); } + }, v.release); + } + + /** A landing sound gives its voice to a newer one: the voice is free at once, the sound fades out over LAND_STEAL_S. */ + _stealLanding(v) { + v.release(); + this.landStats.stolen += 1; + if (!v.src || !v.gain || !this.ctx) return; + try { + const t = this.ctx.currentTime; + const g = v.gain.gain; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0, t + LAND_STEAL_S); + v.src.stop(t + LAND_STEAL_S + 0.005); + } catch { /* ignore */ } + } + + /** + * Fetch the generic landing sounds ahead (b_char_set / b_char_tokenset, ~30 KB together), so the round start's first + * landings never wait for a download (a wait would bunch the chain's first sounds together). The field view asks when a + * match screen comes up (render/app.js createFieldView); before the first gesture, or while the SFX are silent, the wish + * is kept (`landWarm`) and _unlock / setVolumes fetch then. An own deploy sound loads with the first landing that uses it. + */ + warmLandings() { + if (!this.ctx || this.volumes.muted || this.volumes.sfx <= 0) { this.landWarm = true; return; } + this.landWarm = false; + const b = this.getManifest()?.audio?.sfx?.battle; + for (const url of [b?.deploy, b?.tokenDeploy]) if (typeof url === 'string') this._buffer(url); + } + /** * React to `b.ev` tuples (DESIGN §8.2). * @param {any[]} ev diff --git a/public/js/diag.js b/public/js/diag.js index 4132fca7..544e5d33 100644 --- a/public/js/diag.js +++ b/public/js/diag.js @@ -282,7 +282,7 @@ export function buildDiagnostics(input) { b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`); } if (settings) { - lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`); + lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.ammoCount ? 'ammo count' : null, settings.muted ? 'muted' : null)}`); } const tl = trafficLine(trafficTotals); if (tl) lines.push(`traffic: ${tl}`); diff --git a/public/js/render/app.js b/public/js/render/app.js index a8cb77f9..d405b45f 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) @@ -53,7 +61,8 @@ // view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is // chosen / the move is in flight (null releases; setPrep then settles it) // view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) -// view.setSettings({ damageNumbers, officialDamageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy() +// view.setSettings({ damageNumbers, officialDamageNumbers, ammoCount, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy() +// (ammoCount: 设置「弹药数量」, the count under an ammo skill's bar — PR #552) // view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf) // // Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen @@ -134,14 +143,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'; @@ -149,7 +158,7 @@ export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). * @param {HTMLElement} host - * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, quality?: string }, signal?: AbortSignal, + * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, ammoCount?: boolean, quality?: string }, signal?: AbortSignal, * padding?: object|((kind:string, size:{width:number,height:number}) => object), * hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts] */ @@ -162,7 +171,7 @@ export async function createFieldView(host, options = {}) { signal?.throwIfAborted(); const assets = resolveAssets(opts.assets); const data = makeData(opts.data); - const settings = { damageNumbers: true, officialDamageNumbers: false, quality: 'high', ...(opts.settings || {}) }; + const settings = { damageNumbers: true, officialDamageNumbers: false, ammoCount: false, quality: 'high', ...(opts.settings || {}) }; // the 3D board (three.js + the official art) loads in parallel with everything else const boardPref = boardPreference(opts.board); const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d'); @@ -270,6 +279,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 +319,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), @@ -324,6 +336,9 @@ export async function createFieldView(host, options = {}) { loadLevel: () => loadLevel, surfaceLayer: (row) => tiles.surfaceLayer(row), }; + // the prep landings' generic sounds ahead of the first round start (audio.js warmLandings: a download would bunch the + // replay's first landings together); a wish kept until the first gesture / audible SFX + try { ctx.audio?.warmLandings?.(); } catch { /* the sound is optional */ } const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim }); setupCleanup.push(() => tiles.destroy()); const fx = new FxSystem({ @@ -748,6 +763,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 +800,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 +823,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 +858,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 +948,7 @@ export async function createFieldView(host, options = {}) { function enterPrepMode() { mode = 'prep'; held.clear(); + cancelReplay(); clearViews(); infos.clear(); gone.clear(); @@ -1323,6 +1398,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 +1892,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, @@ -1909,6 +1986,7 @@ export async function createFieldView(host, options = {}) { const q = settings.quality; if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers; if (typeof s.officialDamageNumbers === 'boolean') settings.officialDamageNumbers = s.officialDamageNumbers; + if (typeof s.ammoCount === 'boolean') settings.ammoCount = s.ammoCount; // the units read it every frame (ctx.settings) if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality; if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); } }, @@ -1936,6 +2014,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 +2043,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..b398d5da 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -2,7 +2,8 @@ // // UnitView = shadow sprite (shadow layer) + body container (depth-sorted unit layer: elite aura, Spine actor or // the avatar-in-rarity-diamond fallback) + HUD container (bar layer: HP bar with delayed "ghost" damage, SP bar -// with ready glow / draining skill bar, tier chip, status icons, blocked marker). The fallback shows at once and +// with ready glow / draining skill bar — an ammo skill's segmented magazine, with its count under it when 设置「弹药数量」 +// is on (PR #552) —, tier chip, status icons, blocked marker). The fallback shows at once and // cross-fades to the Spine model when it has loaded (the game never blocks on Spine); an optional local-client model // (assets.js spineEntry `fallback`, DESIGN §13: 灼热源石虫 / 炽焰源石虫) falls back to the web model first; that web alias // (the plain 源石虫) is drawn tinted toward the slug's own colours (ALIAS_TINT). A model that failed or timed out is @@ -35,6 +36,14 @@ // 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 and mute, in landing voices of its own that hand the oldest one's voice to +// the newest instead of dropping it); `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 +158,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,10 +461,10 @@ 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, - * settings: { damageNumbers: boolean, quality: string } }} ViewCtx + * assets: any, audio?: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number, + * settings: { damageNumbers: boolean, quality: string, ammoCount?: boolean } }} ViewCtx */ export class UnitView { @@ -565,6 +579,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) @@ -792,6 +807,7 @@ export class UnitView { this.blockIcon.visible = false; this.ctx.layers.groundFx.addChild(this.blockIcon); this.countText = null; + this.ammoText = null; // 设置「弹药数量」: the magazine's count under the ammo bar (_updateAmmoCount) this.itemPips = []; } @@ -1060,6 +1076,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); @@ -1445,8 +1499,10 @@ export class UnitView { ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy); } // 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a - // knocked-down operator (b.snap `down`) - const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0); + // knocked-down operator (b.snap `down`). 设置「弹药数量」 (off by default) first puts the magazine's count right under + // the ammo bar, and the rows below move down by its height + let rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0); + rowsTop += this._updateAmmoCount(showSp && !!ammo && this.ctx.settings?.ammoCount === true, x0 + bw / 2, rowsTop, s); const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s); const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s); this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t); @@ -1474,6 +1530,29 @@ export class UnitView { } } + /** + * 设置「弹药数量」 (PR #552 by @IceCodeNew; off by default — the official HUD has no such number, the maintainer's decision + * of 2026-10-13): the rounds left / the rounds in the magazine (b.snap `ammo`: the exact counts, 拉特兰's extra rounds + * and refills included) centred at `x` under the ammo bar, its top at `top`, in the bar's yellow — readable even where the + * cells are too narrow for their cuts. Built on first use, hidden otherwise (the skill over, the prep view, a death, an + * older snapshot without the row). Returns the height it takes (0 while hidden) so the rows under the bars move down. + */ + _updateAmmoCount(show, x, top, s) { + let text = this.ammoText; + if (!show) { if (text) text.visible = false; return 0; } + if (!text) { + text = this.ammoText = new this.P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: COLORS.ammo, stroke: '#0b0f0e', strokeThickness: 3 }); + text.anchor.set(0.5, 0); + this.hud.addChild(text); + } + const label = `${this.ammo[0]}/${this.ammo[1]}`; + if (text.text !== label) text.text = label; + text.scale.set(clamp(s * 0.2, 10, 16) / 16); + text.position.set(x, top + 3); + text.visible = true; + return text.height + 6; + } + /** * The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round * magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px 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/public/js/ui/gameLogic/settings.js b/public/js/ui/gameLogic/settings.js index d9be9b77..2b1d6b6e 100644 --- a/public/js/ui/gameLogic/settings.js +++ b/public/js/ui/gameLogic/settings.js @@ -23,14 +23,16 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js'; export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']); /** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. - * textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */ -export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS }); + * textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). ammoCount: 设置「弹药数量」 — the + * rounds left / the magazine under an ammo skill's bar (render/units.js; PR #552), off by default: the official HUD has no + * such number (the maintainer's decision of 2026-10-13). */ +export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, ammoCount: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS }); const QUALITIES = ['high', 'medium', 'low']; /** * Sanitize persisted settings. * @param {any} raw - * @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, quality: 'high'|'medium'|'low', + * @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, ammoCount: boolean, quality: 'high'|'medium'|'low', * textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }} */ export function sanitizeSettings(raw) { @@ -45,6 +47,7 @@ export function sanitizeSettings(raw) { muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted, damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers, officialDamageNumbers: typeof r.officialDamageNumbers === 'boolean' ? r.officialDamageNumbers : false, + ammoCount: typeof r.ammoCount === 'boolean' ? r.ammoCount : DEFAULT_SETTINGS.ammoCount, quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality, textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize, keys: sanitizeHotkeys(r.keys), diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index 2009f076..9db0635d 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -1,4 +1,5 @@ -// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, 文字大小, the +// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, the optional 弹药数量 under an ammo +// skill's bar — not in the official HUD, off by default, PR #552 —, render quality, 文字大小, the // shortcut keys): a tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager // and to the document on every change, plus the settings modal — which also holds the language switch (ui/lang.js; kept // apart in `sp.pref.lang`; under it a note while the current language's pack is a machine translation, @@ -29,7 +30,7 @@ import { copyText } from './clipboard.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); -/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, quality, textSize, keys }. */ +/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, officialDamageNumbers, ammoCount, quality, textSize, keys }. */ export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null))); /** @@ -265,6 +266,8 @@ export function SettingsModal({ open, onClose }) { <${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} /> <${Toggle} label=${t('官方伤害数字')} micro="OFFICIAL NUMBERS" value=${s.officialDamageNumbers} onChange=${(v) => updateSettings({ officialDamageNumbers: v })} />

${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}

+ <${Toggle} label=${t('弹药数量')} micro="AMMO COUNT" value=${s.ammoCount} onChange=${(v) => updateSettings({ ammoCount: v })} /> +

${t('弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。')}

${t('画面质量')}<${MicroLabel}>QUALITY
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/hud-readouts.test.js b/test/render/hud-readouts.test.js index 012935f4..1510b671 100644 --- a/test/render/hud-readouts.test.js +++ b/test/render/hud-readouts.test.js @@ -3,6 +3,7 @@ // [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js // draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool // as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js). +// 设置「弹药数量」 (PR #552 by @IceCodeNew, off by default) adds the magazine's count under the ammo bar. // The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js. // Run: node --test test/render/hud-readouts.test.js @@ -166,6 +167,92 @@ describe('UnitView: the ammo skill\'s segmented bar', () => { }); }); +// 设置「弹药数量」 (PR #552 by @IceCodeNew, DESIGN §31.9): the official HUD has no number under the ammo bar, so the count is an +// option, off by default (the maintainer's decision of 2026-10-13); the settings side is test/ui/ammo-count.test.js +describe('UnitView: the optional ammo count under the bar (设置「弹药数量」)', () => { + const counting = (on = true) => view({}, { settings: { damageNumbers: true, quality: 'high', ammoCount: on } }); + const coinY = (v) => v.coinHud.root.position.y; + + test('off (the default): nothing is drawn under the magazine, and the rows below stay where they were', () => { + const v = view(); + const w = counting(false); + try { + for (const u of [v, w]) { u.sync(sample({ flags: UF.SKILL, ammo: [13, 14], coins: [3, 10] })); frames(u); } + assert.equal(v.ammoText, null, 'never built without the setting'); + assert.equal(w.ammoText, null, 'nor with it off'); + assert.equal(v.hud.children.some((c) => c instanceof fake.P.Text && /\d+\/14/.test(c.text)), false); + const plain = view(); + plain.sync(sample({ coins: [3, 10] })); + frames(plain); + assert.equal(coinY(v), coinY(w)); + assert.ok(coinY(v) > coinY(plain), 'the magazine row (thicker than the SP bar) is all that sits above the purse'); + plain.destroy(); + } finally { v.destroy(); w.destroy(); } + }); + + test('on: the exact count through spending, refills and capacity changes, also where the cells are too narrow for cuts', () => { + const v = counting(); + try { + for (const [left, max] of [[13, 14], [12, 14], [137, 240], [136, 240], [260, 260], [0, 260]]) { + v.sync(sample({ flags: UF.SKILL, ammo: [left, max] })); + frames(v); + assert.equal(v.ammoText?.text, `${left}/${max}`); + assert.equal(v.ammoText.visible, true); + if (max >= 240) assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'a dense magazine draws no cuts, the count still reads'); + } + const bw = barWidth(v); + assert.ok(Math.abs(v.ammoText.position.x - (v.spFill.position.x + bw / 2)) < 1e-9, 'centred under the bar'); + assert.ok(v.ammoText.position.y > v.spFill.position.y, 'below the magazine'); + assert.equal(v.ammoText.anchor.x, 0.5); + assert.equal(v.ammoText.style.fill, COLORS.ammo, 'in the bar\'s yellow'); + } finally { v.destroy(); } + }); + + test('on: hidden when the skill ends, without the snapshot row, in the prep view, after death and for enemies; the rows below make room', () => { + const v = counting(); + try { + v.sync(sample({ flags: UF.SKILL, ammo: [5, 14], coins: [3, 10] })); + frames(v); + const withCount = coinY(v); + v.sync(sample({ sp: 6, spMax: 24, coins: [3, 10] })); // the magazine is spent: the SP bar again + frames(v); + assert.equal(v.ammoText.visible, false, 'no stale number after the skill'); + assert.ok(withCount > coinY(v), 'the purse row moved down by the count\'s height while it showed'); + v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] })); + v.prep = true; + frames(v); + assert.equal(v.ammoText.visible, false, 'the prep view draws no bars'); + v.prep = false; + frames(v); + assert.equal(v.ammoText.visible, true); + v.die(); + frames(v); + assert.equal(v.ammoText.visible, false, 'a dead unit has no bars'); + } finally { v.destroy(); } + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }, { settings: { damageNumbers: true, quality: 'high', ammoCount: true } }); + try { + e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] })); + frames(e); + assert.equal(e.ammoText, null, 'enemies have no magazine, so no count'); + } finally { e.destroy(); } + }); + + test('turning the setting off takes the count away at once (the field view shares its settings object with the units)', () => { + const v = counting(); + try { + v.sync(sample({ flags: UF.SKILL, ammo: [9, 14] })); + frames(v); + assert.equal(v.ammoText.visible, true); + v.ctx.settings.ammoCount = false; + frames(v); + assert.equal(v.ammoText.visible, false); + v.ctx.settings.ammoCount = true; + frames(v); + assert.equal(v.ammoText.visible, true); + } finally { v.destroy(); } + }); +}); + describe('UnitView: 伺夜\'s 狼影 pips', () => { const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra }); 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/ammo-count.test.js b/test/ui/ammo-count.test.js new file mode 100644 index 00000000..fb5f83e3 --- /dev/null +++ b/test/ui/ammo-count.test.js @@ -0,0 +1,58 @@ +// test/ui/ammo-count.test.js — 设置「弹药数量」 (PR #552 by @IceCodeNew, ported in 0.2.6, DESIGN §31.9): the remaining ammo +// count under an ammo skill's bar is not part of the official HUD, so it is an option, off by default (the maintainer's +// decision of 2026-10-13, like 「官方伤害数字」). Settings normalisation, the dialog's toggle and its hint in every language +// pack, the field view's live setting, the diagnostics line. What the renderer draws: test/render/hud-readouts.test.js. +// Run: node --test test/ui/ammo-count.test.js + +import { test } 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 { sanitizeSettings, DEFAULT_SETTINGS } from '../../public/js/ui/gameLogic.js'; +import { buildDiagnostics } from '../../public/js/diag.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (p) => readFileSync(path.join(ROOT, p), 'utf8'); +const LABEL = '弹药数量'; +const HINT = '弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。'; + +test('ammoCount is stored with the other settings: off by default and for a profile saved before it, a boolean kept, junk off', () => { + assert.equal(DEFAULT_SETTINGS.ammoCount, false); + assert.equal(sanitizeSettings(null).ammoCount, false); + assert.equal(sanitizeSettings({ bgm: 0.4, officialDamageNumbers: true }).ammoCount, false, 'a profile saved before 0.2.6'); + assert.equal(sanitizeSettings({ ammoCount: true }).ammoCount, true); + assert.equal(sanitizeSettings({ ammoCount: false }).ammoCount, false); + for (const bad of ['true', 1, 0, null, {}, []]) assert.equal(sanitizeSettings({ ammoCount: bad }).ammoCount, false, JSON.stringify(bad)); + // independent of the damage-number switches + assert.deepEqual(sanitizeSettings({ ammoCount: true, damageNumbers: false, officialDamageNumbers: true }), { ...sanitizeSettings({ damageNumbers: false, officialDamageNumbers: true }), ammoCount: true }); +}); + +test('the settings dialog has the toggle and a hint that the official interface has no such number, in every language pack', () => { + const src = read('public/js/ui/settings.js'); + assert.match(src, /<\$\{Toggle\} label=\$\{t\('弹药数量'\)\} micro="AMMO COUNT" value=\$\{s\.ammoCount\} onChange=\$\{\(v\) => updateSettings\(\{ ammoCount: v \}\)\} \/>/); + assert.ok(src.includes(`t('${HINT}')`), 'the hint under it'); + assert.ok(src.indexOf("t('官方伤害数字')") < src.indexOf(`t('${LABEL}')`), 'beside the damage-number options'); + for (const lang of ['en', 'ja', 'ko', 'zh-TW']) { + const pack = JSON.parse(read(`public/i18n/${lang}.json`)); + for (const id of [LABEL, HINT]) assert.ok(typeof pack[id] === 'string' && pack[id].length > 0, `${lang}: ${id}`); + assert.match(pack[HINT], /13\/14/, `${lang}: the example count`); + } +}); + +test('the field view takes the setting at creation and live (setSettings); the units read it from their shared settings', () => { + const app = read('public/js/render/app.js'); + assert.match(app, /const settings = \{ damageNumbers: true, officialDamageNumbers: false, ammoCount: false, quality: 'high', \.\.\.\(opts\.settings \|\| \{\}\) \};/); + assert.match(app, /if \(typeof s\.ammoCount === 'boolean'\) settings\.ammoCount = s\.ammoCount;/); + const units = read('public/js/render/units.js'); + assert.match(units, /this\._updateAmmoCount\(showSp && !!ammo && this\.ctx\.settings\?\.ammoCount === true, x0 \+ bw \/ 2, rowsTop, s\)/); + // the simplified board draws no ammo bar (its SP bar is the tuple's SP), so it has nothing to count under + assert.doesNotMatch(read('public/js/ui/fallbackField.js'), /ammo/); +}); + +test('the diagnostics name the option when it is on', () => { + const state = { me: {}, room: null, match: { public: null, battle: null }, connection: { status: 'online' } }; + const line = (settings) => buildDiagnostics({ state, env: {}, settings }).split('\n').find((l) => l.startsWith('settings:')); + assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: true }), 'settings: quality high | damage numbers | ammo count'); + assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: false }), 'settings: quality high | damage numbers'); +}); diff --git a/test/ui/deploy-replay.e2e.test.js b/test/ui/deploy-replay.e2e.test.js new file mode 100644 index 00000000..c6a73370 --- /dev/null +++ b/test/ui/deploy-replay.e2e.test.js @@ -0,0 +1,163 @@ +// 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) that +// really starts in the page's unlocked audio (audio.landStats), never more than LAND_VOICES at once; 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), sample how many landing voices ring, and + // unlock the page's audio with a key press (a user gesture; Shift is no shortcut) so the landings really start + await page.evaluate(async () => { + const { audio } = await import('/js/audio.js'); + globalThis.__audio = audio; + const cues = globalThis.__cues = []; + const real = audio.deployCue.bind(audio); + audio.deployCue = (info, key) => { cues.push({ key, at: performance.now() }); return real(info, key); }; + globalThis.__mostVoices = 0; + setInterval(() => { globalThis.__mostVoices = Math.max(globalThis.__mostVoices, audio.landVoices.length); }, 4); + }); + await page.keyboard.press('Shift'); + await page.waitForFunction(() => globalThis.__audio.unlocked, { timeout: 5000 }); + // the field view asked for the generic landing sounds ahead (audio.warmLandings); the gesture fetched them + await page.waitForFunction(() => [...globalThis.__audio.buffers.keys()].some((u) => /b_char_set/.test(u)), { timeout: 5000 }); + await sleep(300); + const stats0 = await page.evaluate(() => ({ ...globalThis.__audio.landStats })); + 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'); + // every landing's sound really started — most of them share b_char_set — and never more than LAND_VOICES at once + await sleep(200); + const voices = await page.evaluate(async () => { + const { LAND_VOICES } = await import('/js/audio.js'); + const st = globalThis.__audio.landStats; + return { started: st.started, stolen: st.stolen, most: globalThis.__mostVoices, cap: LAND_VOICES }; + }); + assert.equal(voices.started - stats0.started, order.length, `all ${order.length} landing sounds started (${JSON.stringify(voices)})`); + assert.ok(voices.most <= voices.cap, `at most ${voices.cap} landing voices at once (${voices.most})`); + assert.equal(voices.stolen - stats0.stolen, Math.max(0, order.length - voices.cap), 'the oldest gave its voice to each landing over the cap'); + + // ---- 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..acbe9dbb --- /dev/null +++ b/test/ui/deploy-replay.test.js @@ -0,0 +1,296 @@ +// 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) in its own voices: at most LAND_VOICES at once, a new landing taking the oldest one's +// voice instead of being dropped (Grok's review of r37: the battle limiter left 6 of 8 b_char_set landings silent) — +// checked with the real AudioManager, SfxLimiter and buffer cache on a fake Web Audio context. 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, SfxLimiter, LAND_VOICES, deploySfxUrl } from '../../public/js/audio.js'; +import { mediaUrl } from '../../public/js/media.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)'); + }); +}); + +// ---- the landing voices: the real AudioManager (SfxLimiter, _buffer, the nodes) on a fake Web Audio context -------------- + +const SET = manifest.audio.sfx.battle.deploy; // b_char_set: 192 of the 210 operators land with it +const TOKEN_SET = manifest.audio.sfx.battle.tokenDeploy; +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).filter((k) => !manifest.audio.sfx.units[k]?.born); +/** Seconds of the files as served (b_char_set 1.41 s, b_char_tokenset 0.50 s — afinfo); 1 s for any other. */ +const durationOf = (url) => (/b_char_set/.test(url) ? 1.41 : /b_char_tokenset/.test(url) ? 0.5 : 1); + +/** + * A fake Web Audio window: every buffer source records when it started and stopped on a clock the test moves (`at(ms)`: + * performance.now() and ctx.currentTime follow it, a source that reaches its end fires `onended`); fetch serves every + * sound with its real length. `hold(url)` keeps one download open until the returned function is called. + */ +function landingRig({ settings = { sfx: 0.8, muted: false } } = {}) { + const clock = { ms: 0 }; + const sources = []; + const fetched = []; + const held = new Map(); + class Param { + constructor() { this.value = 1; this.ramps = []; } + setValueAtTime(v) { this.value = v; } linearRampToValueAtTime(v, t) { this.ramps.push([v, t]); } setTargetAtTime(v) { this.value = v; } + cancelScheduledValues() {} + } + class Node { connect() {} disconnect() {} } + class Gain extends Node { constructor() { super(); this.gain = new Param(); } } + class Src extends Node { + constructor() { super(); this.playbackRate = new Param(); this.buffer = null; this.startedAt = null; this.stoppedAt = null; this.stopWhen = null; this.ended = false; sources.push(this); } + start() { this.startedAt = clock.ms; } + stop(when) { this.stoppedAt = clock.ms; this.stopWhen = when; } + get playing() { return this.startedAt != null && this.stoppedAt == null && !this.ended; } + } + class Ctx { + constructor() { this.state = 'running'; this.destination = new Node(); this.sampleRate = 44100; } + get currentTime() { return clock.ms / 1000; } + createGain() { return new Gain(); } + createBufferSource() { return new Src(); } + createBuffer() { return { duration: 0 }; } + decodeAudioData(ab, ok) { ok({ duration: ab.duration, length: 1, numberOfChannels: 1, url: ab.url }); } + resume() { return Promise.resolve(); } + suspend() { return Promise.resolve(); } + } + const listeners = new Map(); + const win = { AudioContext: Ctx, document: { hidden: false, addEventListener() {} }, addEventListener(t, fn) { listeners.set(t, fn); }, removeEventListener(t) { listeners.delete(t); } }; + const realFetch = globalThis.fetch; + const perf = globalThis.performance; + const realNow = perf.now; + globalThis.fetch = async (u) => { + fetched.push(u); + const wait = held.get(u); + if (wait) await wait; + return { ok: true, headers: { get: () => 'audio/mpeg' }, arrayBuffer: async () => ({ duration: durationOf(u), url: u }) }; + }; + perf.now = () => clock.ms; + const a = new AudioManager({ win, getManifest: () => manifest, random: () => 0 }); + a.setVolumes(settings); + a.install(); + a.warmLandings(); // the field view's wish when the match screen comes up, before any gesture (render/app.js) + const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); }; + return { + a, sources, fetched, + unlock: async () => { listeners.get('pointerdown')?.(); await flush(); }, + /** Move the clock to `ms`: a playing source whose file ran out ends (onended), as Web Audio does. */ + at: async (ms) => { + clock.ms = ms; + for (const s of sources) if (s.playing && s.buffer && ms >= s.startedAt + s.buffer.duration * 1000) { s.ended = true; s.onended?.(); } + await flush(); + }, + flush, + hold: (url) => { let open; held.set(mediaUrl(url), new Promise((r) => { open = r; })); return () => open(); }, + playing: () => sources.filter((s) => s.playing && s.buffer?.duration > 0), + started: () => sources.filter((s) => s.startedAt != null && s.buffer?.duration > 0), + restore: () => { globalThis.fetch = realFetch; perf.now = realNow; }, + }; +} + +describe('AudioManager.deployCue — the landing sound in its own voices, taken over instead of dropped', () => { + test(`8 operators on b_char_set, 50 ms apart: 8 starts, never more than ${LAND_VOICES} at once, the oldest taken over each time`, async () => { + assert.equal(LAND_VOICES, 3); + // the battle rule this replaces: ≤ 2 copies of a file at once and the rest dropped — 2 of the 8 would have sounded + const battle = new SfxLimiter(); + assert.equal([...Array(8).keys()].filter((i) => battle.tryAcquire(i * 50, `land:p:${i}`, SET)).length, 2); + const r = landingRig(); + try { + await r.unlock(); + const most = []; + for (let i = 0; i < 8; i++) { + await r.at(i * 50); + assert.equal(r.a.deployCue({ def: PLAIN[i], defId: `chess_${i}`, kind: 'op' }, `p:${i}`), true); + await r.flush(); + most.push(r.playing().length); + const started = r.started(); + assert.equal(started.length, i + 1, `landing #${i} started at once`); + assert.equal(started[i].startedAt, i * 50); + assert.equal(started[i].buffer.url, mediaUrl(SET), 'b_char_set'); + if (i >= LAND_VOICES) { + const old = started[i - LAND_VOICES]; + assert.equal(old.stoppedAt, i * 50, `#${i} took the voice of #${i - LAND_VOICES}, the oldest still ringing`); + assert.ok(Math.abs(old.stopWhen - (i * 50) / 1000 - 0.035) < 1e-9, 'which fades out over 30 ms instead of clicking'); + assert.deepEqual(r.a.landVoices.length, LAND_VOICES); + } + } + assert.ok(Math.max(...most) <= LAND_VOICES, `at most ${LAND_VOICES} at once (${most.join(',')})`); + assert.deepEqual(most, [1, 2, 3, 3, 3, 3, 3, 3]); + assert.deepEqual(r.a.landStats, { started: 8, stolen: 5 }); + // the newest three ring out; the other battle sounds' limiter was never touched + await r.at(350 + 1500); + assert.equal(r.playing().length, 0); + assert.equal(r.a.landVoices.length, 0, 'every voice returned'); + assert.equal(r.a.landLimiter.active, 0); + assert.equal(r.a.limiter.active, 0); + assert.equal(r.a.limiter.lastByUrl.size, 0, 'the battle limiter knows nothing of landings'); + } finally { r.restore(); } + }); + + test('a full board of 36 pieces — b_char_set, own deploy sounds and summons — lands every one within the cap', async () => { + const r = landingRig(); + try { + await r.unlock(); + let most = 0; + for (let i = 0; i < 36; i++) { + await r.at(i * 50); + const info = i % 6 === 5 ? { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' } + : i % 9 === 4 ? { def: OWN, defId: `chess_${i}`, kind: 'op' } : { def: PLAIN[i], defId: `chess_${i}`, kind: 'op' }; + r.a.deployCue(info, `p:${i}`); + await r.flush(); + most = Math.max(most, r.playing().length, r.a.landVoices.length); + } + assert.equal(r.started().length, 36, 'every landing started'); + assert.ok(most <= LAND_VOICES, `never more than ${LAND_VOICES} at once (${most})`); + assert.equal(r.a.landStats.started, 36); + const files = new Set(r.started().map((s) => s.buffer.url)); + assert.ok(files.has(mediaUrl(SET)) && files.has(mediaUrl(manifest.audio.sfx.units[OWN].born)) && files.size >= 3, 'the generic, an own and a summon sound'); + await r.at(36 * 50 + 1500); + assert.equal(r.a.landVoices.length, 0); + assert.equal(r.a.landLimiter.active, 0); + } finally { r.restore(); } + }); + + test('one landing never sounds twice: the same piece within the cooldown takes no voice and stops nothing', async () => { + const r = landingRig(); + try { + await r.unlock(); + for (let i = 0; i < LAND_VOICES; i++) { await r.at(i * 50); r.a.deployCue({ def: PLAIN[i], kind: 'op' }, `p:${i}`); await r.flush(); } + await r.at(LAND_VOICES * 50); + r.a.deployCue({ def: PLAIN[LAND_VOICES - 1], kind: 'op' }, `p:${LAND_VOICES - 1}`); // 50 ms after its own landing + await r.flush(); + assert.equal(r.started().length, LAND_VOICES, 'no second start'); + assert.equal(r.started().filter((s) => s.stoppedAt != null).length, 0, 'and nothing taken over for it'); + await r.at(LAND_VOICES * 50 + 200); + r.a.deployCue({ def: PLAIN[0], kind: 'op' }, 'p:0'); // a later landing of the same piece + await r.flush(); + assert.equal(r.started().length, LAND_VOICES + 1); + } finally { r.restore(); } + }); + + test('the sound of the piece\'s battle deployment at its gain: own ON_UNIT_BORN file, else b_char_set / b_char_tokenset', async () => { + const r = landingRig(); + try { + await r.unlock(); + assert.equal(r.a.deployCue({ def: OWN, defId: 'chess_x', kind: 'op' }, 'p:3'), true); + assert.equal(r.a.deployCue({ def: PLAIN[0], defId: 'chess_y', kind: 'op' }, 'p:4'), true); + assert.equal(r.a.deployCue({ def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' }, 'p:5'), true); + await r.flush(); + const token = deploySfxUrl(manifest, { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', side: 'ally' }); + assert.deepEqual(r.a.landVoices.map((v) => v.url), [manifest.audio.sfx.units[OWN].born, SET, token]); + assert.equal(token, manifest.audio.sfx.units.token_10000_silent_healrb?.born ?? TOKEN_SET, 'a summon: the token sound'); + assert.equal(r.a.landVoices[1].gain.gain.value, 0.5, 'the generic file at the battle deploy\'s gain'); + assert.ok(r.a.landVoices[0].gain.gain.value > 0 && r.a.landVoices[0].gain.gain.value <= 0.8, 'an own file at its bank gain'); + } finally { r.restore(); } + }); + + test('a landing taken over while its file still downloads never starts; the generic files are fetched ahead', async () => { + const r = landingRig(); + try { + assert.equal(r.fetched.length, 0, 'the field view\'s wish waits for the first gesture'); + await r.unlock(); + assert.deepEqual(r.fetched, [mediaUrl(SET), mediaUrl(TOKEN_SET)], 'fetched at unlock: a round start never waits for them'); + const born = manifest.audio.sfx.units[OWN].born; + const open = r.hold(born); + r.a.deployCue({ def: OWN, kind: 'op' }, 'p:1'); // still downloading … + for (let i = 0; i < LAND_VOICES; i++) { await r.at(50 + i * 50); r.a.deployCue({ def: PLAIN[i], kind: 'op' }, `p:${10 + i}`); await r.flush(); } + assert.equal(r.a.landStats.stolen, 1, '… when the pool filled up it gave its voice'); + open(); + await r.flush(); + assert.equal(r.started().filter((s) => s.buffer.url === mediaUrl(born)).length, 0, 'and it never starts'); + assert.equal(r.started().length, LAND_VOICES); + } finally { r.restore(); } + }); + + test('the SFX volume and mute apply: muted or at 0 nothing is fetched, started or taken over; a manifest without the sound: nothing', async () => { + const r = landingRig({ settings: { sfx: 0.8, muted: true } }); + try { + await r.unlock(); + assert.equal(r.fetched.length, 0, 'no warm-up while muted'); + r.a.deployCue({ def: PLAIN[0], kind: 'op' }, 'p:1'); + r.a.setVolumes({ sfx: 0, muted: false }); + r.a.deployCue({ def: PLAIN[1], kind: 'op' }, 'p:2'); + await r.flush(); + assert.deepEqual([r.fetched.length, r.started().length, r.a.landVoices.length], [0, 0, 0]); + r.a.setVolumes({ sfx: 0.8, muted: false }); + await r.flush(); + assert.deepEqual(r.fetched, [mediaUrl(SET), mediaUrl(TOKEN_SET)], 'audible again: the kept wish fetches them now'); + r.a.deployCue({ def: PLAIN[2], kind: 'op' }, 'p:3'); + await r.flush(); + assert.equal(r.started().length, 1); + } finally { r.restore(); } + const bare = new AudioManager({ win: null, getManifest: () => ({ audio: { sfx: { battle: {}, units: {} } } }) }); + assert.equal(bare.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + const none = new AudioManager({ win: null, getManifest: () => null }); + assert.equal(none.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + assert.equal(none.deployCue(null, 1), false); + }); +}); diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index 3516af4b..302c05ed 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -722,7 +722,7 @@ describe('keyboard & settings', () => { test('sanitizeSettings', () => { assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS }); assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }), - { bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } }, + { bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, ammoCount: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } }, 'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)'); assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default'); // 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub