diff --git a/CHANGELOG.md b/CHANGELOG.md index 7359941c..d3a21e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 官方战场场景的光照改回提取出的官方值:直射光和烘焙光不再被压暗,场景不再明显偏暗(#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 6ba56e89..29e2e6af 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -87,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 的决定);高普尼克免疫晕眩、冻结和浮空。 @@ -179,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 07c7cedd..1fbe7a65 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -44,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). --- @@ -52,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 不触发准备或暂停。 @@ -215,3 +215,11 @@ lands in a hidden tab. Battle deploys keep the silent `onDeploy`; audio.handleBa - [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 f8b6fd73..dbf168a6 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -176,3 +176,31 @@ the files, the limiter keys, volume and mute), `test/render/playtest6-promote.te check `test/ui/deploy-replay.e2e.test.js` (`SP_E2E=1`, mock harness: SETTLE → 回合开始 replays the board in deployment order about 50 ms apart with one sound each, once per round and again the next round; in the 休整期 a purchase lands, a move within its Start sounds nothing more, a later move lands, a turn in place does not). Golden: unchanged (client only). + +### 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/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 0cbe6949..4587e339 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -61,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 @@ -157,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] */ @@ -170,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'); @@ -1982,6 +1983,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(); } }, diff --git a/public/js/render/units.js b/public/js/render/units.js index 88719970..697ca022 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 @@ -462,7 +463,7 @@ function bar(P, parent, color, alpha = 1) { * 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, audio?: any, cam: () => any, heightAt: (r:number,c:number)=>number, fx: any, time: () => number, - * settings: { damageNumbers: boolean, quality: string } }} ViewCtx + * settings: { damageNumbers: boolean, quality: string, ammoCount?: boolean } }} ViewCtx */ export class UnitView { @@ -805,6 +806,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 = []; } @@ -1496,8 +1498,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); @@ -1525,6 +1529,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/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/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/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/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