diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 4ed62f68..d6b76dc0 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 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 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 10510a7f..c20691fc 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/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/screens/game.js b/public/js/screens/game.js index 7aa61f9f..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 @@ -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/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/gameLogic/loadout.js b/public/js/ui/gameLogic/loadout.js index ac4f9017..38e1beab 100644 --- a/public/js/ui/gameLogic/loadout.js +++ b/public/js/ui/gameLogic/loadout.js @@ -5,6 +5,15 @@ import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutR import { isObj } from './shared.js'; import { t } from '../../../../shared/i18n.js'; +/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */ +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; +} // ---- operator loadout (DESIGN §16) ---------------------------------------------------------------------------------- diff --git a/shared/protocol.js b/shared/protocol.js index 3fe5c07a..a8beaa9e 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -282,13 +282,14 @@ const statView = (x) => ({ * runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid * (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included, not a kit's * target-selection grid (the sim's `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌 - * S3's 4-11 never reached the card). - * @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | null} u + * S3's 4-11 never reached the card). `dir`: the unit's facing now (UP / RIGHT / DOWN / LEFT; UnitInfo.dir is the facing + * at send time and the snapshot tuples carry none) — the detail card's range overlay rotates by it (GitHub PR #281). + * @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any, dir?: string } | null} u * @param {any} [s] aggregated stats (missing ⇒ the base) * @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number, * def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number, * base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number }, - * range?: Array<[number, number]> }} + * range?: Array<[number, number]>, dir?: string }} */ export function unitStatsEntry(u, s = null) { const base = u && u.base && typeof u.base === 'object' ? u.base : {}; @@ -305,6 +306,7 @@ export function unitStatsEntry(u, s = null) { ...statView(cur), base: statView(base), ...(range ? { range } : {}), + ...(isDir(u?.dir) ? { dir: u.dir } : {}), // the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced silenced: !!(cur.flags && cur.flags.silence), }; diff --git a/test/ui/inspect-range.e2e.test.js b/test/ui/inspect-range.e2e.test.js new file mode 100644 index 00000000..770d1ae4 --- /dev/null +++ b/test/ui/inspect-range.e2e.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { startServer } from '../../server/index.js'; +import { makeBattle } from '../helpers/battleHarness.js'; +import { spawnMapChar } from '../../server/sim/content/tokens.js'; + +const chrome = process.env.CHROME_PATH; +const enabled = process.env.SP_E2E === '1' && chrome && existsSync(chrome); + +test('user Given a real battle with strategy medics When clicking units and closing details Then attack tiles follow selection', + { skip: !enabled && 'Set SP_E2E=1 and CHROME_PATH', timeout: 60000 }, async () => { + const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + const puppeteer = (await import('puppeteer-core')).default; + const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'] }); + try { + for (const medicId of ['char_613_acmedc', 'char_605_cmedic']) { + const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], autoFinish: false }); + h.step(); + assert.ok(spawnMapChar(h.b, h.b.players[0].playerId, medicId)); + h.step(); + const field = h.b.fieldMeta(); + const snapshot = h.b.snapshot(); + assert.equal(field.units.length, 2); + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.port}/dev/game-mock.html?render=fallback&shot=1&phase=PREP`); + await page.waitForSelector('.ff-piece'); + await page.evaluate(async ({ field, snapshot }) => { + const { store } = await import('/js/store.js'); + const { net } = await import('/js/net.js'); + const pub = store.get().match.public; + store.patch('match', { public: { ...pub, phase: 'COMBAT', fields: [{ fieldId: field.fieldId, players: [pub.players[0].playerId] }] }, field }); + // The local runner emits the snapshot synchronously, before the screen's field-entry effect. + net._emit('b.snap', snapshot); + }, { field, snapshot }); + await page.waitForSelector('.ff-unit'); + for (let i = 0; i < field.units.length; i++) { + const units = await page.$$('.ff-unit'); + await units[i].click(); + await page.waitForSelector('.dpanel'); + await page.waitForSelector('.ff-tile.is-range'); + const lit = await page.$$eval('.ff-tile.is-range', (els) => els.map((e) => [Number(e.dataset.row), Number(e.dataset.col)])); + const u = field.units[i]; + if (u.defId === medicId) { + assert.ok(lit.some(([r, c]) => r === 10 && c === 5), 'medic reaches three columns right from the stage tile'); + assert.ok(!lit.some(([, c]) => c === 6), 'inactive skill does not extend the base range'); + } + // A new wire snapshot must refresh the open overlay, not just the unit sprite. + await page.evaluate(async ({ snapshot, id }) => { + const { net } = await import('/js/net.js'); + net._emit('b.snap', { ...snapshot, units: snapshot.units.map((v) => v[0] === id ? [v[0], v[1] + 1, ...v.slice(2)] : v) }); + }, { snapshot, id: u.id }); + await page.waitForFunction((before) => { + const now = [...document.querySelectorAll('.ff-tile.is-range')].map((e) => [Number(e.dataset.row), Number(e.dataset.col)]); + return now.length === before.length && now.every(([r, c]) => before.some(([br, bc]) => br === r && bc + 1 === c)); + }, {}, lit); + await page.evaluate(async () => { + const { store } = await import('/js/store.js'); + store.patch('match', { public: { ...store.get().match.public, phase: 'SETTLE' } }); + // Allow Preact's after-paint phase effects to run before asserting retained UI. + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + assert.ok(await page.$('.dpanel'), 'same-field settlement keeps the inspected card'); + assert.ok(await page.$('.ff-tile.is-range'), 'same-field settlement keeps its range'); + await page.click('.dpanel__close'); + await page.waitForFunction(() => !document.querySelector('.ff-tile.is-range')); + await page.evaluate(async (snapshot) => { + const { net } = await import('/js/net.js'); + net._emit('b.snap', snapshot); + }, snapshot); + } + await (await page.$$('.ff-unit'))[0].click(); + await page.waitForSelector('.ff-tile.is-range'); + await page.evaluate(async ({ field, snapshot }) => { + const { store } = await import('/js/store.js'); + const { net } = await import('/js/net.js'); + const fieldId = `${field.fieldId}:next`; + store.patch('match', { field: { ...field, fieldId } }); + net._emit('b.snap', { ...snapshot, fieldId }); + }, { field, snapshot }); + await page.waitForFunction(() => !document.querySelector('.dpanel') && !document.querySelector('.ff-tile.is-range')); + assert.deepEqual(errors, []); + await page.close(); + } + } finally { + await browser.close(); + await server.close(); + } + }); diff --git a/test/ui/inspect-range.test.js b/test/ui/inspect-range.test.js new file mode 100644 index 00000000..49e1674e --- /dev/null +++ b/test/ui/inspect-range.test.js @@ -0,0 +1,106 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { inspectRange } from '../../public/js/screens/game/range.js'; +import { renderInfo } from '../../public/js/render/app/info.js'; +import { rangeTiles } from '../../public/js/ui/facing.js'; +import { unitStatsEntry } from '../../shared/protocol.js'; +import { makeBattle } from '../helpers/battleHarness.js'; + +const read = (name) => JSON.parse(readFileSync(new URL(`../../data/${name}.json`, import.meta.url), 'utf8')); +const chess = read('chess'), tokens = read('tokens'); +const getChess = (id) => chess[id]; +const medic = (id = 'char_613_acmedc', extra = {}) => renderInfo({ id: 71, defId: id, kind: 'token', side: 'ally', x: 5, y: 9, dir: 'UP', ...extra }); +const args = (unit, extra = {}) => ({ target: { kind: 'unit', unit }, detail: { type: 'token', token: tokens[unit.defId] }, + getChess, field: { prep: true }, ...extra }); + +test('user Given Jessica inspected beside her shield When it expires Then her open range turns back with her', () => { + const h = makeBattle({ autoFinish: false, units: [ + { uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_1034_jesca2', skillIndex: 0 }, row: 10, col: 4 }, + { uid: 2, kind: 'token', tokenId: 'token_10032_jesca2_jckshd', ownerUid: 1, row: 11, col: 4 }, + ] }); + h.step(); + const u = h.unit(1); + const unit = h.b.fieldMeta().units.find((v) => v.id === u.id); + const input = { target: { kind: 'unit', unit }, detail: { type: 'chess', chess: u.def.raw, diy: true }, getChess, field: {} }; + const tiles = () => { + const r = inspectRange({ ...input, live: unitStatsEntry(u, u.s), snapshot: h.b.snapshot().units.find((v) => v[0] === u.id) }); + return rangeTiles(r.grid, r.row, r.col, r.dir); + }; + assert.deepEqual(tiles(), [[10, 4], [11, 4], [12, 4]]); + h.b.retreat(h.unit(2), { reason: 'expired', permanent: true }); + h.run(0.5); + assert.deepEqual(tiles(), [[10, 4], [10, 5], [10, 6]]); +}); + +test('user Given strategy medics When inspecting them Then their healing tiles rotate on the field', () => { + for (const id of ['char_613_acmedc', 'char_605_cmedic']) { + const r = inspectRange(args(medic(id))); + assert.equal(r.grid.length, 12); + const tiles = rangeTiles(r.grid, r.row, r.col, r.dir); + assert.equal(tiles.length, 12); + assert.ok(tiles.some(([row, col]) => row === 12 && col === 4)); + assert.ok(!tiles.some(([row]) => row === 8)); + } +}); + +test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => { + const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } })); + assert.equal(r.grid.length, 18); + assert.deepEqual([r.row, r.col, r.dir], [10, 8, 'UP']); + const tiles = rangeTiles(r.grid, r.row, r.col, r.dir); + assert.ok(tiles.some(([row, col]) => row === 15 && col === 9)); + assert.ok(!tiles.some(([, col]) => col === 5)); +}); + +test('user Given an enemy, scouted hand or absent battle unit When inspecting Then no attack tiles appear', () => { + for (const extra of [{ side: 'enemy' }, { area: 'hand' }, { area: 'temp' }, { kind: 'device' }]) { + assert.equal(inspectRange(args(medic(undefined, extra))), null); + } + assert.equal(inspectRange(args(medic(), { field: { prep: false } })), null); + assert.equal(inspectRange(args(medic(), { field: {}, snapshot: [71, 5, 9, 0, 200] })), null); + assert.equal(inspectRange(args(medic(), { showPrep: true })), null); + assert.equal(inspectRange(args(medic(), { live: { range: [] } })), null); +}); + +test('user Given an own board operator When its details open Then its range uses the board direction and clears off board', () => { + const piece = { kind: 'chess', id: 'chess_char_1_01_a', uid: 5, dir: 'LEFT' }; + const input = { target: { kind: 'piece', uid: 5 }, detail: { type: 'chess', chess: chess[piece.id], piece }, + pieces: new Map([[5, { piece, area: 'board', row: 11, col: 2 }]]), showPrep: true, getChess }; + const r = inspectRange(input); + const tiles = rangeTiles(r.grid, r.row, r.col, r.dir); + assert.ok(tiles.every(([, col]) => col >= 0 && col <= 2)); + assert.ok(tiles.some(([, col]) => col === 0)); + assert.equal(inspectRange({ ...input, pieces: new Map() }), null); + assert.equal(inspectRange({ ...input, target: { kind: 'chess', id: piece.id } }), null); + assert.equal(inspectRange({ ...input, showPrep: false }), null); +}); + +test('a scouted prep board (the server\'s m.field): the board operator draws its range, the bench (area hand / temp) none', async () => { + const { makeMatch, give, giveItem, legalTileFor, chessOfTier } = await import('../match/harness.js'); + const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const b = h.ps('p_1'); + m.handle('p_1', { t: 'g.ready', ready: false }); + for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) if (p.kind === 'chess') b.returnCopies(p); + b.board.clear(); b.hand.fill(null); b.temp.fill(null); b.recompute(); + const [id1, id2, id3] = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x)); + const deployed = give(m, b, id1); + const [r, c] = legalTileFor(m, b, id1); + m.handle('p_1', { t: 'g.move', uid: deployed.uid, to: { area: 'board', row: r, col: c }, dir: 'UP' }); + const held = give(m, b, id2); + while (b.hand.some((x) => x == null)) giveItem(m, b, 'chess_item_6_09_e_a'); // a full hand keeps the temp piece in temp + const waiting = give(m, b, id3, 'temp'); + m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }); + const field = h.lastTo('p_0', 'm.field'); + const range = (piece) => { + const unit = renderInfo(field.units.find((u) => u.uid === piece.uid)); + return inspectRange({ target: { kind: 'unit', unit }, detail: { type: 'chess', chess: chess[piece.id] }, field, getChess }); + }; + const own = range(deployed); + assert.deepEqual([own.row, own.col, own.dir], [r, c, 'UP']); + assert.equal(range(held), null, 'a held operator (area hand) has no field range'); + assert.equal(range(waiting), null, 'nor one in the 临时整备区 (area temp)'); + m.dispose(); +}); diff --git a/test/ui/playtest6_summons.test.js b/test/ui/playtest6_summons.test.js index 54cfa639..c6f6e423 100644 --- a/test/ui/playtest6_summons.test.js +++ b/test/ui/playtest6_summons.test.js @@ -23,7 +23,8 @@ globalThis.fetch = async (url) => { } }; -const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js'); +const { TokenDetail, summonDeployHint, resolveDetail } = await import('../../public/js/ui/detailPanel.js'); +const { tokenVariantFor } = await import('../../public/js/ui/gameLogic/loadout.js'); const { indexPieces } = await import('../../public/js/ui/gameLogic.js'); const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js'); const simTokens = await import('../../server/sim/content/tokens.js');