diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 700f9ed4..29ac54d0 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -74,7 +74,7 @@ - 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。 - **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。 - 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 -- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。 +- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它——只有速射手例外:攻击范围内有飞行敌人时先打飞行敌人(特性「优先攻击空中单位」,维护者 2026-10-08 的决定,GitHub #220)(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 @@ -137,7 +137,7 @@ - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。 - **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。 -- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 +- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏标出他在场上的干员;休整期查看时他整备区、临时整备区里的干员也会列出、标为「整备区」,和他自己看到的一样计数:投资人、远见、奇迹这类含整备区的盟约把整备区的算进成员人数,临时整备区的哪个盟约都不算;作战中只列出场上的);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 ## 9. 结算称号 @@ -171,7 +171,7 @@ | `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 | | `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) | -刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 +刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 **修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 diff --git a/docs/design/client.md b/docs/design/client.md index 4f9fd6ad..458554ba 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -46,7 +46,7 @@ Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立 A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26). -Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). +Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). 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. diff --git a/docs/design/network.md b/docs/design/network.md index 5f2e70e6..d3f48ca0 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -33,7 +33,7 @@ S→C: interpolate linearly; nothing is inferred from the attack animation. - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` -- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) +- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5) `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index 1cda7adb..94773851 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } /* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */ .fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; } +/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */ +.fwheel:focus-visible { outline: none; } .fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } .fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); } @keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } } diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index 51885de7..3e2d983b 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -41,6 +41,8 @@ export function renderInfo(u) { // skill aura is drawn for it (render/fx/rings.js _aura) ammoSkill: u.ammoSkill === true ? true : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + // Scouted hand and temp units have details but no field attack range. + area: typeof u.area === 'string' ? u.area : undefined, // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own // unit takes its items from the piece instead, so only other players' boards ever read this field diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 0796e434..4319e8d5 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -101,7 +101,12 @@ export class FxSim { else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true); break; } - case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break; + // 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a + // stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the + // unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field + case 'chill': + if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); } + break; case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break; case 'healAoe': { this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6); diff --git a/public/js/render/fx/zones.js b/public/js/render/fx/zones.js index 3dcad273..a6ad4f86 100644 --- a/public/js/render/fx/zones.js +++ b/public/js/render/fx/zones.js @@ -180,11 +180,32 @@ export class FxZones { this.tintSprite.tint = tint; } - /** A short flurry of snow over the field (cold wind). */ - snowfall(tint) { + /** + * Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the + * bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the + * community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind + * with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts, + * speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an + * operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before. + */ + snowfall(tint, gust = false) { const size = this.ctx.screenSize(); - for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) { - this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); + const W = size.width, H = size.height, low = this.quality === 'low'; + if (!gust) { + for (let i = 0; i < (low ? 10 : 26); i++) { + this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); + } + return; + } + // snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust + for (let i = 0; i < (low ? 22 : 48); i++) { + const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1); + this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 }); + } + // gust streaks: thin wind lines racing up through the field along their velocity + for (let i = 0; i < (low ? 4 : 10); i++) { + const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12); + this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) }); } } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 93a4e18d..b26ad5f1 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -117,6 +117,7 @@ import { useDocClass, FullscreenButton } from '../ui/device.js'; import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js'; import { keepEarly, audioEarly } from './game/early.js'; import { MatchEnded, PausedOverlay } from './game/overlays.js'; +import { inspectRange } from './game/range.js'; import { t, tParts } from '../../../shared/i18n.js'; /** Router for the in-match screens. */ @@ -477,6 +478,9 @@ function MatchScreen() { } if (earlySnap) { view.pushSnapshot(earlySnap); + if (Array.isArray(earlySnap.units)) { + snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t])); + } hudRef.current = snapHud(earlySnap); setHud(hudRef.current); } @@ -1045,10 +1049,6 @@ function MatchScreen() { }, []); // ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------ - // DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a - // chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord) - const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item, - chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]); const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private const releaseHold = useCallback((uid) => { heldRef.current.delete(uid); @@ -1100,16 +1100,10 @@ function MatchScreen() { useEffect(() => { if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing(); }, [editable, placeCtx, facing]); - // the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing) + // the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below) const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null; live.current.showPrep = showPrep; useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]); - const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : ''; - useEffect(() => { - if (!view || !selRangeKey) return undefined; - showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE); - return () => showRange(view, null, 0, 0, null, SEL_RANGE); - }, [view, selRangeKey]); // the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2 // item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every // panel anyway (css z-index), this keeps it visible too @@ -1296,7 +1290,7 @@ function MatchScreen() { }, [strip.ownerId]); // the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name, // and as members your pieces or their operators on the field on screen — under client-side combat the battle's own - // (the runner's field meta is taken before they deploy); their hand is never sent + // (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard) const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null; const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers }); const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); }; @@ -1350,6 +1344,23 @@ function MatchScreen() { } return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards })(); + // the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281): + // an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally + // operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's + // tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen + // draw their own + const cardRange = !drag && !facing && !pen ? inspectRange({ + target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field, + snapshot: snapUnitsRef.current.get(resolved?.unitId), + live: typeof liveStats === 'function' ? liveStats() : liveStats, + loadout: detailLoadout, getChess: gd.chess, backups: gd.backups, + }) : null; + const cardRangeKey = cardRange ? JSON.stringify(cardRange) : ''; + useEffect(() => { + if (!view) return undefined; + showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE); + return () => showRange(view, null, 0, 0, null, SEL_RANGE); + }, [view, cardRangeKey, showPrep, field]); // the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that // teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward // cards read the strip's (ui/watchBonds.js detailBondOwner) diff --git a/public/js/screens/game/range.js b/public/js/screens/game/range.js new file mode 100644 index 00000000..969d528e --- /dev/null +++ b/public/js/screens/game/range.js @@ -0,0 +1,32 @@ +// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by +// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange. +import { attackRangeGrid } from '../../../../shared/loadoutRecord.js'; +import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js'; +import { tokenVariantFor } from '../../ui/gameLogic/loadout.js'; +import { normDir } from '../../ui/facing.js'; + +/** Resolve the inspected field unit without granting placement or sale actions. */ +export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) { + if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null; + let row, col, dir; + if (target?.kind === 'piece' && showPrep) { + const e = pieces?.get(target.uid); + if (!e || e.area !== 'board') return null; + row = e.row; col = e.col; dir = normDir(e.piece.dir); + } else if (target?.kind === 'unit' && !showPrep) { + const unit = target.unit; + if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null; + if (unit.area != null && unit.area !== 'board') return null; + // Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation. + if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null; + row = Math.round(snapshot ? snapshot[2] : unit.y); + col = Math.round(snapshot ? snapshot[1] : unit.x); + dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT'); + } else return null; + if (!Number.isFinite(row) || !Number.isFinite(col)) return null; + const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) } + : detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn + : chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess; + const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record); + return grid?.length ? { grid, row, col, dir } : null; +} diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index b656f4f4..e606bc9f 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -17,7 +17,9 @@ // already did. // Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers: // the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings, -// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent). +// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench +// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub +// #385); a battle sends no bench. // 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so — // UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row // opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06). diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 272c06ea..5a41b815 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { moduleBadge, fullTraitText } from './loadoutModel.js'; import { t, tc, N_ } from '../../../shared/i18n.js'; import { audio } from '../audio.js'; +import { tokenVariantFor } from './gameLogic/loadout.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -580,21 +581,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY) : t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)'); } -/** - * The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b` - * entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one. - * @param {any} token tokens.json record - * @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon) - */ -export function tokenVariantFor(token, ownerId = null) { - const vs = token?.variants || {}; - if (typeof ownerId === 'string') { - const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')]; - if (v) return v; - } - return Object.values(vs)[0] || null; -} - /** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */ function tokenOwnerId(piece, pieces) { if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null; diff --git a/public/js/ui/facingWheel.js b/public/js/ui/facingWheel.js index a4987ccf..c688a1d8 100644 --- a/public/js/ui/facingWheel.js +++ b/public/js/ui/facingWheel.js @@ -13,7 +13,8 @@ // LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction; // * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap // on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a -// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events. +// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused +// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events. // // View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`, // `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls @@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks import { html } from './components.js'; import { LocalSprite } from './gameComponents.js'; import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js'; -import { facingSwallows } from './gameLogic.js'; +import { facingSwallows, facingEnter } from './gameLogic.js'; import { settingsStore } from './settings.js'; import { t } from '../../../shared/i18n.js'; @@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm }; const onPointerCancel = () => { setDrag(null); setDir(null); }; - // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them) + // keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter + // follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD + // behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it + // opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then + // reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space). + const rootRef = useRef(null); useEffect(() => { const onKey = (e) => { const L = live.current; if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; } const k = dirFromKey(e.key); if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; } - if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; } + const enter = facingEnter(e, !!L.dir); + if (enter) { + e.preventDefault(); e.stopImmediatePropagation(); + if (enter === 'cancel') L.onCancel(); + else if (enter === 'commit') L.onCommit(L.bdir); + return; + } // no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键) if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); } }; window.addEventListener('keydown', onKey, true); + try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ } return () => window.removeEventListener('keydown', onKey, true); }, []); const outside = !!drag && !!dir; const box = half * 2; - return html`