Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners

- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+6 -4
View File
@@ -19,6 +19,8 @@
- **干员调配**(大厅 / 房间 / 本局信息确认时):和官方一样不能调整等阶,但可为每名干员选择携带的技能(283 个技能全部手工实现)与精锐的模组(或不装备)。
- 联防、最终攻势(两人共享战场与领袖血池)、隐秘核心;同盟模拟断线 10 分钟内可重连(掉线期间按原阵容自动作战,也可「暂离」交给 AI 托管),独立模拟 24 小时内可回来继续。
- 真实 Spine 小人、官方 BGM / 音效、表情(6 套 × 6 个);可选的官方 3D 棋盘(需从本机客户端提取贴图)。
- 作战特效:狙击曳光弹与枪口火光、法术 / 治疗 / 敌方光球、抛物线炮弹与爆炸、回环射手的回旋镖(飞出再飞回)、蕾缪安 3 技能的锁定与逐发炮击、近战斩击弧光、技能光柱与脚下光环;低画质只保留必要特效。
- 漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都以画面上实际画出的干员为准。
- **战斗在各玩家浏览器里模拟**(和官方一样),服务器只管经济与回合:一台低功耗 Windows 小主机即可开服。
## 快速开始
@@ -75,11 +77,11 @@ npm start # http://localhost:3000
| 操作 | 方法 |
|---|---|
| 购买 / 升级调度中心 | 点一次选中,再点一次确认(`D` 升级) |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上/右/下/左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消 |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上/右/下/左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动中模型站在的格子就是落点(触屏时模型在手指上方) |
| 调整朝向 | 把干员拖回它自己的格子,再选方向 |
| 出售 / 撤退 / 销毁装备 | 点击单位 → 底部按钮「出售 +1」「撤退」;整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区)。也可以把棋盘上的干员拖回整备区撤退 |
| 装备 | 把装备拖到干员身上(每人 2 件;满了会弹出替换窗口,选择要替换的一件——它会被销毁);法术拖到地块上并选方向 |
| 查看详情 | 右键或长按单位 / 卡牌 |
| 装备 | 把装备拖到干员身上(以图标所在的干员为准,目标干员的格子会高亮;每人 2 件;满了会弹出替换窗口,选择要替换的一件——它会被销毁);法术拖到地块上并选方向 |
| 查看详情 | 右键或长按单位 / 卡牌(干员的特质显示在最上方) |
| 快捷键 | `R` 刷新 · `F` 冻结 · `D` 升级 · `Space` 准备就绪 · `Esc` 取消 / 关闭 |
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
@@ -109,7 +111,7 @@ npm start # http://localhost:3000
## 测试
```bash
node --test # 单元 + 集成测试(约 2300 项;缺少素材 / 浏览器的用例会自动跳过)
node --test # 单元 + 集成测试(约 2400 项;缺少素材 / 浏览器的用例会自动跳过)
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材
```
+2
View File
@@ -212,6 +212,8 @@ Other renderer rules from research 07 §5.4–5.5:
- Without a Spine, draw the avatar sprite with a bob tween.
- `spineVariant` names the skin-variant model that stands in for the missing default model.
- **Enemies without a spine** (for example `enemy_9016_acstmr`): draw `icon` in a diamond. Enemies with no manifest entry at all (`enemy_5601_entlec` 心烛): draw a procedural glyph.
- **Battle effects** (projectiles per kind, hit sparks and slashes, skill bursts and auras, 蕾缪安's lock reticles and shells, 回环射手 boomerangs — DESIGN §17.3) are procedural: the FX atlas is drawn at run time (`public/js/render/textures.js`), so they need no downloaded or local art. The local client does have battle effect art — `battle/[pack]common.ab` holds per-weapon projectile sprites (`projectile_arrow(_new)`, `projectile_crossbow(_new)`, `projectile_yuki`, `img_fx_light_01/02`, `trail_11`), and the per-character `battle/prefabs/effects/*.ab` are particle systems whose textures live in other bundles — but none of it is extracted: the sim's `arrow` also covers gun snipers, and friends joining a game may not have the local art.
- **Spine memory** (`public/js/assets.js`): skeletons are refcounted in an LRU with an idle budget (`SPINE_IDLE_BYTES`, 48 MB) and a 15 s grace; eviction runs `SPINE_EVICT_DELAY_MS` (1 s) after a release, the "no scene on screen" budget (0 bytes) applies after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced, and a skeleton whose unload is still in flight is never handed out again — a new load waits for the unload (DESIGN §17.1; it used to leave operators invisible after a battle → prep switch).
### Looking up `data/chess.json` asset ids
+61 -11
View File
@@ -99,7 +99,7 @@ public/
screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js
ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js,
tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js
render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js (API §9)
render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js, pick.js (API §9)
vendor/ pixi, pixi-spine, preact, hooks, htm (copied from node_modules by tools/vendor.mjs)
assets/ downloaded art/audio (git-ignored), see docs/ASSETS.md
fonts/ self-hosted fonts
@@ -292,7 +292,7 @@ export default {
```
The kit loader passes `bb` = flattened blackboard `{ key: value }` of the chess's skill (normal or elite), and `chess` = data record (for talents' blackboards). Missing kit ⇒ a **generic kit** derived from blackboard keys (atk/attack_speed/def/max_hp/base_attack_time/atk_scale/max_target/duration/ammo) so every chess always works.
Profession traits (default behaviours implemented once in the engine, keyed by `subProfessionId`): e.g. `fastshot` (fly priority), `aoesniper`/`splashcaster` (splash 1.1 tiles), `chain` caster (bounce ×3, −15%), `funnel`, `blastcaster`, `physician` (single heal), `ringhealer` (aoe heal), `bard` (aura heal, no attack), `slower` (slow on hit), `underminer`, `instructor`, `lord` (ranged attack ×0.8 when not melee), `centurion`/`fighter`/`musha`/`swordmaster`/`reaper` etc., `protector`/`guardian`/`fortress`/`duelist`, `pioneer`/`charger`/`tactician`/`bearer`/`agent`, `executor`, `merchant`, `hookmaster`, `pusher`, `stalker`, `geek`, `dollkeeper`, `traper`, `craftsman`, `summoner` … (build-data provides the list of subprofessions present in the pool; the sim-core owner implements all present ones with simplified faithful behaviour).
Profession traits (default behaviours implemented once in the engine, keyed by `subProfessionId`): e.g. `fastshot` (fly priority), `aoesniper`/`splashcaster` (splash 1.1 tiles), `chain` caster (bounce ×3, −15%), `funnel`, `blastcaster`, `physician` (single heal), `ringhealer` (aoe heal), `bard` (aura heal, no attack), `slower` (slow on hit), `loopshooter` (回环射手: a boomerang out to the target and back, attacks again only once it is caught — §17.4), `underminer`, `instructor`, `lord` (ranged attack ×0.8 when not melee), `centurion`/`fighter`/`musha`/`swordmaster`/`reaper` etc., `protector`/`guardian`/`fortress`/`duelist`, `pioneer`/`charger`/`tactician`/`bearer`/`agent`, `executor`, `merchant`, `hookmaster`, `pusher`, `stalker`, `geek`, `dollkeeper`, `traper`, `craftsman`, `summoner` … (build-data provides the list of subprofessions present in the pool; the sim-core owner implements all present ones with simplified faithful behaviour).
---
@@ -303,10 +303,13 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
```
LOBBY(room) → INFO_CHECK (co-op 25 s, solo untimed; all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, 12 s/turn, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ band_bldsk; solo: free pick, no timer) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp hand wiped, unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this
prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards untimed)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; auto-resolve temp overflow: temp items destroyed/ops sold is NOT allowed → ready is blocked while temp non-empty; on timeout temp contents are sold/destroyed automatically)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
the temp pieces due at this prep are resolved (chess sold back, items / summon stacks destroyed) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助:
most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond >
@@ -342,6 +345,8 @@ Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third ite
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite to hand + reward offer of 3 free chess of tier `min(level+1, 6)`; purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; temp blocks ready).
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants, battle-result rewards, equipment returned by a merged / sold carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items / summon stacks destroyed — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite always goes to the hand, overflowing into temp (also outside PREP); it takes a freed board tile of a consumed copy only when the hand and temp are both full. `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02).
@@ -390,7 +395,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMa
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — omitted when 0 and in every other phase (§17.5 live LP) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }`
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
@@ -437,16 +442,20 @@ view.setPrep(privateState, { editable }) // render hand/temp/board pieces as
view.enterBattle(fieldMeta) // switch to battle rendering for m.field
view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer)
view.highlightTiles(tiles, style) // legal/illegal placement, range preview
view.on('pieceDragStart'|'pieceDrop'|'pieceClick'|'pieceHover'|'tileHover', fn)
// pieceDrop: { uid, target: {area:'board',row,col}|{area:'hand',idx}|{area:'outside', clientX, clientY} }
view.on('pieceDragStart'|'pieceDrop'|'pieceDragEnd'|'pieceDragOver'|'pieceClick'|'pieceHover'|'tileHover', fn)
// pieceDrop: { uid, target: {area:'board',row,col}|{area:'hand',idx}|{area:'outside', clientX, clientY}, over }
// area 'outside' lets the DOM UI decide (e.g. dropped on the shop bar ⇒ sell)
view.pieceScreenRect(uid) // for tooltips/overlays
// pieceDragOver { uid, over, clientX, clientY }: while an item is dragged, `over` = the uid of the own operator /
// summon under the dragged plate (null: none; render/pick.js), sent when it changes and BEFORE the drag asks
// canPlace; pieceDrop / pieceDragEnd carry the final `over` (§17.2)
// tileHover while dragging = the drop target: the tile under the dragged ghost's feet (§17.2)
view.pieceScreenRect(uid) // for tooltips/overlays; `.shape` = the drawn body (render/pick.js PickBody)
view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry)
view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
view.resize() ; view.destroy()
```
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, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons; damage numbers (phys orange-white, arts purple, true white, heal green); projectiles and hit sparks; skill activation flash; death fade; leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units.
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, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons; 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; leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` (§17.2); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
---
@@ -454,7 +463,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), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **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), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (stats, skill, talents, 特质, bonds, items); enemy preview drawer (next round enemies with counts, factions); 机变 overlay; merge reward overlay; phase banners; ticker; 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), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **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), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats, skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions); 机变 overlay; merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card and upgrade are two-tap (select → confirm); 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 ⇒ 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 item onto unit ⇒ equip (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; `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small).
@@ -524,7 +533,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`, unchanged). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle`, sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates, authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
@@ -561,3 +570,44 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
---
## 17. User playtest #3 (v2.2): models, picking, FX, rules, HUD
Nine reports from a real co-op session. Where each is handled: #1 lost operator models → §17.1; #2 the LP did not move while enemies entered the own blue gate → §17.5; #3 equipment pushed into the second bench row vanished after the battle → §6.2 temp overflow (server) + §17.5 (UI); #4 蕾缪安 S3 fired every shell at once → §17.4; #5 回环射手 attacked like a fast shooter → §17.4; #6 bullets / skill effects hardly visible → §17.3; #7 a click selected the unit one row lower (equipment too) → §17.2; #8 the operator's own effect (特质) sat at the bottom of the detail card → §17.5; #9 spinners next to texts → §17.5.
### 17.1 Lost operator models (#1) — the Spine store (`public/js/assets.js RefLru`)
Cause: `PIXI.Assets.unload` is asynchronous (the loader cache entry is dropped and the textures destroyed a microtask later), so a skeleton evicted and re-acquired in the same task came back as the doomed object and turned invisible for good (a tier chip without a body; the dead data stayed cached, so every later copy of that operator was invisible too). It happened at every battle → prep switch: `enterPrepMode → clearViews` made the reference count 0 for an instant, the LRU applied its "no scene on screen" budget (0 bytes) at that instant and dropped the bench models idle since the battle started, then `setPrep` took them back at once. Rules now: an `unload` may return a promise and a load of a key whose unload is still in flight waits for it (`unloadSpineData` returns one; `stats().spine.unloading`); the spine store runs eviction in one pass `SPINE_EVICT_DELAY_MS` (1 s) after a release (a scene switch takes its models back first); the quiet budget applies only after `SPINE_QUIET_DELAY_MS` (3 s) with nothing referenced (lobby / room). The count-cap path (60 skeletons) waits the same way. Tests: `test/render/assets.test.js` (fake `PIXI.Assets` with the same async unload), `test/render/models.browser.test.js` (models alive after long battles, drag / wheel stress).
### 17.2 Picking and the drag target (#7) — `render/pick.js`
Cause: every path took the front-most 0.7 × 1.28-tile rectangle under the pointer and looked at the pressed tile only when no rectangle was hit; drawn chibis are 1.17–1.41 tiles tall (median 1.27 over the 138 operator skeletons) while the official cameras put rows only 0.84 (prep) – 0.91 (battle) tile apart on screen, so the front unit's rectangle covered the legs, feet and tile of the unit behind it.
- **One rule** for every "which unit is under the pointer" — prep press / drag start / right-click / long-press detail (`app.js pieceAt`), battle clicks and hover (`battleUnitAt`), the enemy preview pen (`penUnitAt`), the own operator under a dragged item (`pieceUnder`) and the DOM-side fallback (`ui/gameLogic.js pickPieceAt` over `view.pieceScreenRect(uid).shape`): `pickBody(bodies, x, y, groundTile, probe)`. A body (`UnitView.pickShape()`, px of one screen) is the drawn chibi — head ellipse + body box around its feet, its own height measured once from the posed Spine model (`getLocalBounds`), ±0.3 tile wide, facing, draw order, and the HUD it shows (tier chip / bars, an outline only; none while culled) — or the avatar diamond until the model is there, or an item plate.
- **Pixel probe first**: wherever it can change the answer (two or more bodies within their outline + `PROBE_MARGIN` 0.5 tile of the pointer, or one body the shape rule did not pick), `app.js drawnAt` renders each candidate view alone into a 1×1 RenderTexture from its last-frame transforms and reads the pixel back; the **front-most unit whose model draws the pixel under the pointer wins** — what the player sees (hair, arms, weapons included). Measured against the rendered pixels on the mock match: wrong unit 0.7 % of prep presses / 1.7 % in battle (anti-aliased edges), against 2.3 % / 11.1 % for the old rectangle; ≈ 0.7 ms per probed press. Every readback waits for the GPU, so **hover** picks (mouse moves over the field in prep and battle, item-drag moves — `render/drag.js` passes `hitPiece(x, y, hover)` / `dropPoint(e, piece, final)`) probe at most once per rendered frame (`app.js hoverProbeAt`; a second hover pick in the same frame uses the shape rule alone — it only moves a highlight); presses, releases and drops always probe.
- **Shape rule** where no unit draws (a gap, the bare tile) or the probe cannot tell (no readback): (1) a unit's face / torso core → the front-most such unit; (2) else the unit standing on the tile under the pointer; (3) else the nearest core among outline hits. Plain rects without a shape (the DOM fallback field) go to the rect whose centre is nearest relative to its size, ties → the front-most.
- **Drag target = where the ghost stands**: the dragged ghost stands on its drop point — the pointer for mouse / pen, `TOUCH_LIFT_TILES` = 0.6 tile above the finger for touch (so the finger never hides it) — and the drop target, `tileHover`, the target highlight and the direction wheel's tile are the tile under it (`render/drag.js dropPoint` hook). Whether DOM UI covers the drop is tested at the drop point too (a lifted touch ghost over the bench while the finger rests on the shop bar still lands). A dragged item plate is centred on the pointer; the operator under it (`pieceUnder`) is reported by `pieceDragOver` before the drag asks `canPlace`, lit as the target, and equipped on release (`ui/gameLogic.js equipRetarget`). Previously the ghost hung 0.45 tile below the pointer while the target was the tile under the pointer.
- Tests: `test/render/pick.test.js`, `drag.test.js`, `unitview.test.js`, `test/ui/gameLogic.test.js`; `test/render/models.browser.test.js` (pixel-truth accuracy in prep and battle, raised / stacked units, equipment drops, the ghost and wheel tile, the touch bench drop).
### 17.3 Battle FX (#6) — `render/fx.js`, `style.js PROJ`, `textures.js`
- **Projectiles per kind** (`PROJ[kind].look`), sized in tiles × the camera's px per tile (the same on the 3D board and the 2D fallback, 1280×720 to 1920×1080): `arrow` a bright tracer with a glowing head, a muzzle flash aimed at the target and a hit flare; `bolt` / `orb` / `enemy` a glowing ball with a particle trail (arts purple with a burst + shockwave, heal green with a sparkle, enemy red); `bomb` / `lob` a shell on a visible arc with a ground shadow, smoke and embers, exploding on landing (blastcaster shells purple); `drone` a small cyan dart; `boomerang` (回环射手) a spinning boomerang with afterimages that flies out to the moving target at `PROJECTILE_SPEEDS.boomerang` and back to the thrower's **current** position at `BOOMERANG_RETURN_SPEED` (the sim emits no event for the return: the renderer models it; dropped when the thrower's view is gone or dead). A shot lingers 0.08 s in its target so fast shots still read. Flight times use the sim's own speeds, loaded at run time from `/sim/constants.js` (`style.js PROJ` keeps a test-guarded copy: `test/render/fxproj.test.js` fails when they differ), so a shot lands with its damage number.
- **Hits and skills**: a melee blow draws a thick crescent slash swept along the blow (attacker → victim on screen) in the damage-type colour with a white core, plus sparks; skill activation = gold light pillar with a white-hot core, flash, shockwave and hex ring on the ground, rising motes; an active skill keeps a slowly turning hex and a soft glow under the unit. Blasts share one explosion (fireball, flash, shockwave, coloured ring, sparks, smoke; `heavy` kinds — bombard, airstrike, meltdown — add debris and a scorch mark). Beams and chain lightning: glow + body + white core, flare at the target.
- **Anchoring**: an fx whose `extra.id` names a unit is drawn on that unit (following its rendered position) only when the event's (x, y) lies within 0.75 tile of it (or has no position); otherwise at (x, y) — the sim puts the caster in `id` of many area effects. `FX_KINDS` entries flagged `pt` (bombard, bombardShell) always happen at (x, y) (their `id` is the shooter; 蕾缪安's bombard used to explode on herself).
- **蕾缪安 S3 contract**: `'lock' {x, y, id: enemy, src: shooter}` → a reticle following that enemy (its view by `id`), at the spot it left once it is gone; `'bombardShell' {x, y, id: shooter, r, t: flight game s, i}` → a launch streak leaving the shooter and a shell dropping onto (x, y) after `t` (a flight ≥ 0.45 real s climbs out of her first), with a closing warning ring and the blast radius throbbing; it takes the shooter's nearest free lock; `'bombard' {x, y, id: shooter, r}` at the same spot → the heavy explosion and the end of that lock. A lock also ends on the S2 `'crit'` from the same shooter, when the shooter is gone (locks no shell took yet), or `LOCK_T` = 5 game s after that shooter's last lock / shell / bombard (an idle timeout, not an age: ammo grants and stuns stretch an S3 far past 5 s); ≤ 48 reticles.
- **Cost**: particle, projectile and lock records are pooled (the per-frame emitters allocate nothing); a freed particle is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`); cosmetic extras (trails, muzzle flashes, afterimages, debris, scorch marks, motes) are off at quality 'low' and at adaptive load level ≥ 2, and trails stop at 75 % of the particle cap. Measured: the 120-unit stress scene and the crowded boss-m02 replay stay at 60 fps with no measurable per-frame increase. The FX atlas is 1024×512 with non-overlapping frames (the old light pillar carried two status icons at its foot). The FX are procedural: the local client's per-weapon projectile sprites are not used (the sim's `arrow` also covers gun snipers, and friends may not have the local art).
### 17.4 回环射手 (#5) and 蕾缪安 S3 (#4) — the sim (official PRTS notes; numbers from the data)
- **回环射手 boomerang** (`professions.js loopshooter`, `ai.js throwBoomerang`; PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75", "必须回收全部回旋投掷物才可以进行下一次攻击"): projectile `'boomerang'` (`resolveProfile` keeps it over the data's generic `'arrow'`) flies out at `PROJECTILE_SPEEDS.boomerang` = 15 tiles/s and hits on arrival, then flies back to the thrower's current position at `BOOMERANG_RETURN_SPEED` = 3.75 tiles/s without damage; the thrower attacks only while it holds its boomerang (every one it threw caught) **and** its attack cooldown is ready, so the real interval is the longer of the two. A target that died mid-flight is not hit (the boomerang still flies to its last position and back); a thrower knocked out / withdrawn loses it and a redeployed one holds a fresh one. 跃跃 S2's extra boomerangs share the one flight (cnt hits, the talent rolls per hit). Event contract unchanged: `['atk', thrower, target, 'boomerang']`.
- **蕾缪安 S3 礼炮·强制追思** (`kits/tier6.js lemuen`; PRTS S3 note — the timing is not in the blackboard): one lock every 0.5 s while an enemy is in range (unlocked targets first, then lowest DEF), each costing a round; the lock mark follows its enemy and stays where it left (death / leak). After the skill ends ONE shell every 0.3 s in lock order (PRTS "以0.3s为间隔"), aimed at a random point of the 0.4-side square around its mark (± `attack@emit_offset` 0.2, drawn with the battle rng in her facing frame so a turned board plays alike), landing 0.3 s after it is fired (flight **[ASSUMED]**: the data and PRTS give none; the PRTS count of ≤ 33 fits a first impact one interval after the end); each shell hits every enemy within `dist_2` once — `proj_atk_scale_1` within `dist_1`, else `proj_atk_scale_2` — with the ATK cached when the skill ended; at most 10 s of shells (≤ 33). Knocked out / withdrawn after the end: shells in the air still land, the rest are dropped; knocked out while locking: no bombardment. fx: `'lock'` per lock, `'bombardShell'` when a shell is fired, `'bombard'` at its impact (§17.3).
- Tests: `test/sim/professions.test.js`, `test/content/kits_t1t2.test.js` (跃跃), `test/content/kits_t6.test.js` (shell sequence, spacing, cap, knock-out, rotation invariance).
### 17.5 Game HUD (#2, #3, #8, #9) — `ui/hud.js`, `teamPanel.js`, `underframe.js`, `detailPanel.js`, `data.js`
- **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防: the own battle's count stays on show as an upper bound tagged 联防中 (teammates may still stop part of it; the settled loss can be lower). Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`).
- **Temp row (#3, UI)**: while `m.private temp` holds pieces during the own prep, a dashed red frame surrounds the 临时整备区 row on the board and a label says how many are waiting, what to do and when they go (`underframe.js TempRowNotice` / `tempRowFrame`, placed through `view.tileScreen` — it follows the camera, the mirrored Final Assault prep and the DOM fallback; no pointer events; the label hides while dragging or placing). Its wording follows the server rule (§6.2, `tempRowRule(ready)`): not ready — "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁"; ready in PREP (so whatever lies there arrived after Ready) — "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)". The 准备就绪 button shows "临时整备区 N 个单位待处理" under it (not only on hover; the effects column moves down a line meanwhile, `.gm.has-readywhy`) and Space toasts the reason (`hud.js TEMP_RULE` / `tempReadyReason`).
- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden.
- **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final.
- Tests: `test/ui/playtest3.test.js`, `test/match/runner.test.js` (live leak counts), `test/match/temp.test.js` (§6.2), `test/docs-consistency.test.js`.
+19 -11
View File
@@ -33,13 +33,16 @@ server/match/
```
LOBBY → INFO_CHECK (co-op 25 s, solo untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden):
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp wiped (reward offers earned after
the last prep — a SETTLE merge — are kept for this prep),
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp NOT wiped (what overflowed after
the last prep's deadline — battle-result grants, a SETTLE merge's elite, returned equipment — is
shown and usable in this prep, `PlayerState.tempDue`; reward offers earned after the last prep —
a SETTLE merge — are kept for this prep),
shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview),
onRoundStart dispatch
[SP_DRAFT] r ∈ modes[m].spRounds (机变)
PREP onPrepStart; co-op timer rounds[r].prepTime, solo untimed; ends when every alive seat is ready
(prep end) onPrepEnd, temp auto-resolved, reward offers expire, unfrozen shop cleared, funds lost
(prep end) onPrepEnd, the temp pieces due at this prep resolved (tempDue; what arrived after Ready or
during onPrepEnd waits for the next prep), reward offers expire, unfrozen shop cleared, funds lost
(band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check
COMBAT one Battle per alive player (FieldRunner, 2× game speed; limit = 2 × maxPlayTime game s, §3)
UNITE co-op, ≥ 1 leaker and ≥ 1 perfect player (§4)
@@ -83,7 +86,7 @@ text "无需消耗资金"). A `choice:<effectId>` registry handler overrides the
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (temp sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
the deadline (the temp pieces due at that prep sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime`
(86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect`
@@ -332,17 +335,22 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
selling, temp wipes and elimination return exactly what a piece holds (`left + held = cap` always).
selling, temp resolution and elimination return exactly what a piece holds (`left + held = cap` always).
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses buys unless the purchase completes a merge, and withdrawals unless the withdrawn summoner's own
summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new card: `HAND_FULL`,
never temp); temp blocks Ready; temp is resolved (chess sold back to the pool, items destroyed) at the
prep deadline and at the next round start.
never temp); temp blocks Ready. A temp piece is resolved (chess sold back to the pool, items / summon stacks
destroyed) at the deadline of the first prep in which its player could act on it (`PlayerState.tempDue` vs
`prepsEnded`, recorded by `_putTemp`, user playtest #3): arrived during a prep before Ready → that prep's end; after
Ready, during onPrepEnd, or outside PREP (COMBAT, 联防, SETTLE, ROUND_START, 机变) → the end of the NEXT prep, so it
is shown and usable first; `setReady(false)` makes what arrived while ready due at the current prep. The round
start never wipes temp (DESIGN §6.2).
* **Merge**: 3 normal copies (风丸 2) anywhere (board/hand/temp) → elite to the hand (the incoming copy, then temp, hand,
board copies are consumed); equipment returns to the hand; summons of consumed copies are removed; a reward offer of 3
free chess of tier min(level+1, 6) (pick 1, expires at prep end; queued when several merges happen). A merge
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep, and with
a full hand its elite takes a freed board tile of a consumed copy instead of temp (wiped at the round start).
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep; its elite
goes to the hand like any merge's, overflowing into temp (kept through the next prep, see Hand) — it takes a freed
board tile of a consumed copy only when the hand and temp are both full (research 01 A1).
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
@@ -501,5 +509,5 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
field) finish that battle, its LP already merged into the team LP stays there.
* After the LP merge (Final Assault) the per-player LP shown is a share of the team LP ∝ the LP each player brought in.
* A 联防 helper's operators dead at the end of its own combat do not take part in the 联防 battle.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; with a full hand its elite
takes a freed board tile (a prep merge still overflows into temp).
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes to the hand,
overflowing into temp like a prep merge's (temp pieces that arrive after the prep wait through the next prep).
+6 -4
View File
@@ -18,7 +18,7 @@
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,每人 12 秒,队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选华法琳。独立:自由选择、不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加),独立不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -53,8 +53,8 @@
## 3. 招募、整备区与晋升
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位进入**临时整备区**(5 格),下一回合开始时自动销毁。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。原先配发的装备退回整备区。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)的随机干员中**免费**挑 1 名。它不能刷新、不能冻结、本回合结束失效。
- 每回合商店会自动刷新未冻结的格子。
@@ -62,7 +62,9 @@
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息(属性、技能、天赋、特质、盟约、装备)。
- 拖动时干员模型站在的格子就是落点(触屏时模型抬高到手指上方,不会被手指挡住);装备以拖动中的图标所在的干员为目标,目标干员的格子会高亮。
- 点选以画面上实际画出的干员为准:点到谁的头发、手臂、武器或身体就选中谁;点在空地上时按所点的格子判定。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。
- 部署顺序是**从上到下、从左到右**,最后部署的干员嘲讽等级最高;部署费用不够时后面的干员会晚一点上场。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
+34 -6
View File
@@ -615,6 +615,31 @@ export function install(battle) {
}
```
**8. Timed follow-ups — 蕾缪安 `chess_char_6_01` S3 礼炮·强制追思** (user playtest #3; bb `attack@emit_offset 0.2`,
`dist_1` / `dist_2`, `proj_atk_scale_1` / `_2`; PRTS S3 note: after the skill ends one bombardment every 0.3 s in lock
order on a random point of the 0.4-side square around each lock mark, ATK cached at the end, ≤ 33 in 10 s). Content that
unfolds over time chains `battle.after` callbacks owned by the unit (they run even if it dies later — check what the
official text says and bail out yourself); random numbers come from `battle.rng`, drawn in the unit's facing frame
(`dir.js rotateOffset`) so a turned board plays alike:
```js
const bombard = (battle, unit, locks) => {
const atk = unit.s.atk, seq = unit.deploySeq; // ATK cached at the skill's end
const n = Math.min(locks.length, Math.floor(10 / 0.3 + 1e-9)); // ≤ 33 shells
const fire = (i) => {
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return; // knocked out: no further shell
const L = markOf(locks[i]); // follows the enemy / stays where it left
const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir);
const x = L.x + ox, y = L.y + oy;
battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: 0.3, i }); // the renderer drops a shell there
battle.after(0.3, () => blast(battle, unit, atk, x, y), { owner: unit }); // lands 0.3 s later [ASSUMED]
if (i + 1 < n) battle.after(0.3, () => fire(i + 1), { owner: unit });
};
if (n > 0) fire(0);
};
```
(`blast` emits fx `'bombard'` at (x, y) and deals `proj_atk_scale_1` / `_2` × the cached ATK to every enemy within
`dist_2`, once each; the full kit is `content/kits/tier6.js lemuen`.)
---
## 8. Professions (server/sim/professions.js)
@@ -622,10 +647,12 @@ export function install(battle) {
Profile resolution: `PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait tunables (trait text + data
trait.bb, elites include module upgrades) → data fields (dmgType / attackKind / projectile / canHitFly / targetPriority)
→ kit.trait`. Defaults: SNIPER ranged phys arrow · CASTER ranged arts bolt · MEDIC ranged heal · SUPPORT ranged arts ·
TANK/WARRIOR/PIONEER/SPECIAL melee phys (melee cannot hit FLY). Ranged attacks fly as projectiles (arrow 14, bolt 11,
bomb/lob 8, drone 16 tiles/s); melee/`none` hits are instant. Kit-settable profile flags beyond the table: `hitSleep`
(targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, `afterHit`,
`afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal` (see the header of professions.js).
TANK/WARRIOR/PIONEER/SPECIAL melee phys (melee cannot hit FLY). Ranged attacks fly as projectiles
(`constants.js PROJECTILE_SPEEDS`: arrow 14, bolt 11, bomb/lob 8, orb 10, drone 16, enemy 10 tiles/s; boomerang 15 out,
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant. Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says) (see the header of professions.js).
| sub | behaviour |
|---|---|
@@ -635,7 +662,7 @@ bomb/lob 8, drone 16 tiles/s); melee/`none` hits are instant. Kit-settable profi
| aoesniper / splashcaster / blastcaster | splash 1.1 tiles at full damage |
| bombarder | ground-only splash 1.0 + aftershock(s) at 50 % ATK (bb append_atk_scale / times) |
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
| loopshooter | next attack waits for the boomerang (2 × distance / 10 s) |
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) |
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.8-tile jumps, sluggish on each hit |
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
@@ -681,7 +708,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]`
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal`), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|chain|chainHeal`; a boomerang's way back has no event — the
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16)
+4
View File
@@ -188,6 +188,10 @@ Boss HP scaling:
- Funds do **not** carry over [WIKI: "没有用完的资金无法留到下一回合"].
- Exception, band 坎诺特 (`coin_carry_over {capital:5, interest:1, max:1}`): keep leftovers, +1 if leftover ≥ 5.
3. Destroy everything in the 临时整备区 (temporary overflow bench) [tip: "处于临时整备区的调度资源,在进入下一回合后会自动销毁"].
*Superseded in the remake (user playtest #3, DESIGN §6.2):* wiping at the round start destroyed what SETTLE had just put
there (battle-result grants, a SETTLE merge's elite, returned equipment) before the player ever saw it — reported as a
bug by a player of the official mode. The remake resolves a temp piece at the deadline of the first prep in which its
player could act on it (the "next round" of the tip = the combat that follows that prep; Ready stays blocked meanwhile).
4. If `isSpPrepare`: run the 机变阶段 choice (§12) before the shop opens.
5. Free automatic shop refresh of all **non-frozen** slots [ASSUMED, auto-chess convention]. Promotion-reward slots from last round are gone.
6. Fire "<进入休整期时>" effects: bands (e.g. 小贾斯汀 R1/4/7/10, 杰西卡 R4/7/10/13, 松桐 on even rounds…), operator traits (garrison), equipment.
+13 -8
View File
@@ -71,14 +71,19 @@
.btn__icon { width: 1.15em; height: 1.15em; }
.btn__label { position: relative; }
.btn__spin {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin .7s linear infinite;
/* a request in flight (Button `loading`): a thin bar sweeps along the bottom edge, shown only after 250 ms (quick
replies show nothing). Deliberately no spinning ring / wait cursor: next to text those read as "the text is still
loading from the server" (user playtest #3 item 9). */
.btn__busy {
position: absolute; left: 0; right: 0; bottom: 0; height: 2px; overflow: hidden; pointer-events: none;
opacity: 0; animation: btn-busy-in 1ms linear 250ms forwards;
}
.btn__busy::before {
content: ''; position: absolute; top: 0; bottom: 0; left: 0; width: 40%;
background: var(--btn-bd-hover, var(--mint-400)); animation: btn-busy 1.1s var(--ease-in-out) infinite;
}
@keyframes btn-busy-in { to { opacity: 1; } }
@keyframes btn-busy { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@keyframes spin { to { transform: rotate(360deg); } }
/* filled variants carry a hatch strip on the right end (in-game "准备就绪" look) */
@@ -156,7 +161,7 @@
background-position: right center;
background-repeat: no-repeat;
}
.btn.is-loading { cursor: progress; --btn-fg: var(--text-lo); }
.btn.is-loading { cursor: default; --btn-fg: var(--text-lo); }
.btn--primary.is-loading { --btn-bg: var(--mint-700); --btn-fg: var(--text-on-mint); }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: .15rem; gap: .07rem; }
+5 -1
View File
@@ -122,8 +122,12 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
}
/* Short landscape phones (≤ 460 CSS px tall, 1rem = 40 px): the lobby's two columns are compressed so the
创建同盟 button and the join panel stay on screen without scrolling. */
创建同盟 button and the join panel stay on screen without scrolling. The detail card's header is shorter (no
romanised name, a shorter portrait) so the operator's own effect (特质) under it stays in view even in the short card
docked above the shop bar (user playtest #3 item 8). */
@media (max-height: 460px) {
.dhead__art { height: 1.66rem; }
.dhead__en { display: none; }
.lobby-body { padding-top: .2rem; padding-bottom: .2rem; gap: .36rem; }
.mode-cards { margin-bottom: .2rem; }
.mode-card { min-height: 0; gap: .12rem; padding: .22rem .26rem .2rem; }
+2 -2
View File
@@ -41,7 +41,7 @@
}
.ewheel__btn.has-sprite:hover:not(:disabled), .ewheel__btn.has-sprite.is-on { color: #fff; filter: brightness(1.18); border: 0; }
.ewheel__btn.has-sprite:disabled { opacity: 1; color: rgba(242, 242, 242, .45); }
.ewheel__btn.is-cooling { cursor: wait; }
.ewheel__btn.is-cooling { cursor: default; }
.ewheel__label { white-space: nowrap; }
/* ---- panel: one page per theme ----------------------------------------------------------------------------- */
@@ -84,7 +84,7 @@
.ewheel__panel.has-cell .ewheel__item:hover:not(:disabled) { background-color: transparent; filter: brightness(1.35); }
.ewheel__item:active:not(:disabled) { transform: scale(.9); }
.ewheel__item:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 1px; }
.ewheel__item:disabled { opacity: .45; cursor: wait; }
.ewheel__item:disabled { opacity: .45; cursor: default; }
/* pager: ‹ dots › in the strip under the cells */
.ewheel__dots { position: absolute; left: .15rem; width: 2.82rem; top: 1.99rem; height: .12rem; display: flex; align-items: center; justify-content: center; gap: .1rem; }
+52 -9
View File
@@ -102,10 +102,14 @@
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: .14rem; line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait .icon { width: .16rem; height: .16rem; margin-top: .03rem; color: var(--text-lo); flex: none; }
.dstats-wrap { display: flex; gap: .12rem; align-items: stretch; }
.dstats { flex: 1; display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.dstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0f1311; }
.dstat__k { font-size: .13rem; color: var(--text-lo); white-space: nowrap; }
.dstat__v { font-size: .18rem; color: #fff; }
/* compact 4 × 2 grid (label over value): the skill stays close under the 特质 block (user playtest #3 item 8) */
/* (enemy / token cards: the grid is a direct child of the column-flex scroller — it must not grow to fill the card) */
.dstats { flex: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.dstats-wrap > .dstats { flex: 1; }
.dstat { display: flex; flex-direction: column; justify-content: center; gap: .01rem; min-width: 0; padding: .04rem .07rem; background: #0f1311; }
.dstat__k { font-size: .12rem; color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
.drange { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .06rem; padding: .06rem .1rem; background: #0f1311; border: 1px solid var(--line); min-width: 1.1rem; }
.rgrid { --rg: .14rem; display: grid; gap: 2px; }
@@ -134,9 +138,16 @@
.dsp__num .icon { width: .14rem; height: .14rem; color: var(--gold); }
.dtalent { display: flex; flex-direction: column; gap: .02rem; }
.dtalent b { font-size: .15rem; color: var(--text-hi); }
.dgarrison { display: flex; flex-direction: column; gap: .06rem; }
.dgarrison__type { display: inline-flex; align-self: flex-start; align-items: center; gap: .05rem; font-size: .13rem; color: var(--gold-2); padding: .01rem .08rem .01rem .04rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__icon { width: .2rem; height: .2rem; object-fit: contain; }
/* the operator's own effect (特质, user playtest #3 item 8): compact, right under the header, gold accent */
.dgarrison {
display: flex; flex-direction: column; gap: .04rem; padding: .07rem .1rem .08rem; flex: none;
background: linear-gradient(90deg, rgba(242, 189, 62, .1), rgba(242, 189, 62, .02) 70%); border-left: 3px solid var(--gold-2);
}
.dgarrison__head { display: flex; align-items: center; gap: .08rem; }
.dgarrison__k { font-size: .14rem; font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: .12rem; line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__icon { width: .18rem; height: .18rem; object-fit: contain; }
.dgarrison__text { font-size: .15rem; line-height: 1.55; color: var(--text-hi); }
.dbonds { display: flex; flex-wrap: wrap; gap: .08rem; }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: .15rem; background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
/* the unit's bonds in the header's right column, right under name / class (user playtest #2 item 9): icon, name,
@@ -351,7 +362,14 @@
background: rgba(8, 11, 10, .92); border: 1px solid var(--mint-500); border-radius: .3rem; font-size: .13rem; color: var(--mint-400); font-weight: 700; }
.spcard__taker .pavatar { --pa: .4rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.spcard.is-taken .spcard__taker { filter: none; }
.spcard__spin { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0, 0, 0, .4); }
/* the card being picked (g.choice in flight): mint frame + a 选择中 strip with the sweeping busy bar of the buttons
(components.css btn-busy) — no spinner over the card's text (user playtest #3 item 9) */
.spcard.is-busy { border-color: var(--mint-400); box-shadow: inset 0 0 0 1px var(--mint-400); }
.spcard__busy {
position: absolute; left: 0; right: 0; bottom: 0; padding: .05rem 0 .08rem; overflow: hidden; pointer-events: none;
font-size: .14rem; font-weight: 700; letter-spacing: .2em; color: var(--mint-400); background: rgba(8, 11, 10, .85);
}
.spcard__busy::after { content: ''; position: absolute; left: 0; bottom: 0; width: 40%; height: 2px; background: var(--mint-400); animation: btn-busy 1.1s var(--ease-in-out) infinite; }
/* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would
let non-square art overflow its frame). */
@@ -390,6 +408,31 @@
}
.fwheel__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- the temp overflow row's notice (ui/underframe.js TempRowNotice, user playtest #3 item 3): a dashed red frame
around 临时整备区 and a label at its end. Over the field, under every HUD panel (.gm__hud z 3), never takes the
pointer (the pieces under it stay draggable). ---- */
.tempnote { position: fixed; inset: 0; z-index: 2; pointer-events: none; }
.tempnote * { pointer-events: none; }
.tempnote__frame { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.tempnote__frame polygon {
fill: rgba(255, 60, 40, .1); stroke: #ff5a47; stroke-width: 2.2px; stroke-dasharray: 9 5; stroke-linejoin: round;
vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 .05rem rgba(255, 60, 40, .7)); animation: tempnote-pulse 1.6s ease-in-out infinite;
}
@keyframes tempnote-pulse { 50% { opacity: .55; } }
.tempnote__label {
position: absolute; transform: translate(-100%, -50%); display: flex; flex-direction: column; gap: .02rem; max-width: 2.9rem;
padding: .06rem .12rem .07rem .1rem; background: rgba(40, 6, 6, .9); border: 1px solid rgba(255, 90, 71, .75); border-left: 3px solid #ff5a47;
box-shadow: 0 .04rem .16rem rgba(0, 0, 0, .6); color: #ffdcd6; animation: tempnote-in var(--t-med) var(--ease-out);
}
@keyframes tempnote-in { from { opacity: 0; } }
.tempnote__label.is-right { transform: translate(0, -50%); border-left-width: 1px; border-right: 3px solid #ff5a47; }
.tempnote__title { display: flex; align-items: center; gap: .05rem; font-size: .15rem; font-weight: 700; color: #fff; white-space: nowrap; }
.tempnote__title .icon { width: .17rem; height: .17rem; color: #ff6a55; flex: none; }
.tempnote__title .num { color: #ff8a78; font-size: .17rem; }
.tempnote__rule { font-size: .12rem; line-height: 1.45; color: rgba(255, 220, 214, .88); }
@media (prefers-reduced-motion: reduce) { .tempnote__frame polygon, .tempnote__label { animation: none; } }
.sp-reduced-motion .tempnote__frame polygon, .sp-reduced-motion .tempnote__label { animation: none; }
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
It sits above every HUD panel (detail card z 9, bond popup, drawer, shop bar): only its buttons take pointer events,
so they are never covered (user playtest #2 item 8); overlays that must win (机变, modals, the drag ghost) are higher. */
@@ -403,7 +446,7 @@
padding: 0; background: none; border: 0; color: #fff; cursor: pointer; filter: drop-shadow(0 .03rem .06rem rgba(0, 0, 0, .8));
animation: uframe-in 200ms var(--ease-out);
}
.uframe__btn:disabled { opacity: .5; cursor: wait; }
.uframe__btn:disabled { opacity: .5; cursor: default; }
.uframe__btn--retreat { left: 25%; top: 25%; transform: translate(-80%, -90%); }
.uframe__btn--sell, .uframe__btn--destroy { left: 75%; top: 25%; transform: translate(-20%, -90%); }
/* the octagon plate every button stands on (the official util_btn plates are octagons) */
+31 -4
View File
@@ -133,6 +133,23 @@
.lp--danger { color: var(--red-premium); }
.lp--danger .lp__icon { filter: hue-rotate(-140deg) saturate(2.4) brightness(.95); }
.lp--team { color: var(--mint-glow); }
/* the running battle's pending loss (LpTower `pending`, user playtest #3 item 2): the number turns red, a −N plate
pops again on every new leak (keyed), 联防中 while teammates may still save part of it */
.lp.is-pending .lp__val { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 60, 60, .45); }
.lp__pend {
align-self: center; margin-left: .02em; padding: .06em .24em; font-size: .56em; font-weight: 700; line-height: 1.1;
color: #fff; background: #d8262a; box-shadow: 0 0 .1rem rgba(255, 50, 50, .55);
clip-path: polygon(.12em 0, 100% 0, 100% calc(100% - .12em), calc(100% - .12em) 100%, 0 100%, 0 .12em);
animation: lp-pend 520ms var(--ease-out);
}
.lp__note {
align-self: center; padding: .08em .3em; font-size: .4em; font-weight: 700; letter-spacing: .08em; line-height: 1.2;
color: #1a0800; background: var(--orange);
}
.lp--sm .lp__note { font-size: .6em; }
@keyframes lp-pend { 0% { opacity: 0; transform: scale(1.9); } 45% { opacity: 1; transform: scale(.92); } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .lp__pend { animation: none; } }
.sp-reduced-motion .lp__pend { animation: none; }
/* enemy preview button: amber [🔍▶▶] → the pen (research 09 §2); grey [🔍] in the pen view and outside 休整期 */
.enemybtn {
@@ -159,6 +176,13 @@
.readywrap { display: flex; flex-direction: column; align-items: flex-end; gap: .05rem; }
.readywrap__count { font-size: .14rem; color: var(--text-lo); }
.readywrap__count b { color: var(--mint-400); font-size: .17rem; }
/* why 准备就绪 is refused (the temp overflow row holds pieces, user playtest #3 item 3): shown, not only on hover */
.readywrap__why {
display: inline-flex; align-items: center; gap: .05rem; padding: .02rem .1rem .02rem .06rem; font-size: .14rem; font-weight: 700;
color: #ffdcd6; background: rgba(40, 6, 6, .88); border: 1px solid rgba(255, 90, 71, .7); border-right: 3px solid #ff5a47; white-space: nowrap;
}
.readywrap__why .icon { width: .16rem; height: .16rem; color: #ff6a55; flex: none; }
.readywrap__why b { color: #ff8a78; font-size: .16rem; }
.readybtn {
display: flex; align-items: center; gap: .12rem; height: .56rem; padding: 0 .16rem 0 .12rem; min-width: 2.3rem;
background: rgba(10, 14, 12, .9); border: 2px solid var(--mint-500); color: var(--text-hi);
@@ -264,6 +288,9 @@
/* ---- effects ---------------------------------------------------------------------------------------------- */
.gm__effects { position: absolute; right: .3rem; top: 2.4rem; }
/* 准备就绪 refused (the temp row holds pieces): its reason shows under the button and pushes the ready count down —
the effects column follows, or the count runs into it on phones */
.gm.has-readywhy .gm__effects { top: calc(2.4rem + max(.3rem, 16px)); }
.effects { display: flex; flex-direction: column; align-items: flex-end; gap: .08rem; }
.effect {
position: relative; width: .5rem; height: .5rem; display: grid; place-items: center;
@@ -310,7 +337,7 @@
transition: background var(--t-fast), border-color var(--t-fast), transform var(--t-fast), color var(--t-fast);
}
.ewheel__item:hover:not(:disabled) { background: var(--mint-a10); border-color: var(--mint-700); color: var(--mint-400); transform: translateY(-.02rem); }
.ewheel__item:disabled { opacity: .45; cursor: wait; }
.ewheel__item:disabled { opacity: .45; cursor: default; }
.ewheel__art { width: .6rem; height: .6rem; display: grid; place-items: center; }
.ewheel__art:empty { display: none; }
.ewheel__art .eart { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .25)); }
@@ -383,10 +410,10 @@
background: rgba(8, 11, 10, .9); border: 1px solid var(--line-2); border-radius: .3rem; white-space: nowrap;
animation: pop-in var(--t-slow) var(--ease-out);
}
.chud__msg .icon { width: .22rem; height: .22rem; color: var(--gold); animation: hg-flip 2.4s var(--ease-in-out) infinite; }
@keyframes hg-flip { 0%, 40% { transform: rotate(0); } 50%, 90% { transform: rotate(180deg); } 100% { transform: rotate(360deg); } }
/* a still hourglass: a rotating one next to the text read as a loading spinner (user playtest #3 item 9) */
.chud__msg .icon { width: .22rem; height: .22rem; color: var(--gold); }
.chud__msg--dead { border-color: rgba(255, 84, 84, .5); color: var(--red-premium); }
.chud__msg--dead .icon { color: var(--red-premium); animation: none; }
.chud__msg--dead .icon { color: var(--red-premium); }
.vswitch { display: flex; align-items: center; gap: .1rem; height: .5rem; padding: 0 .06rem; background: rgba(8, 11, 10, .9); border: 1px solid var(--line-2); }
.vswitch__arrow { width: .4rem; height: .4rem; display: grid; place-items: center; background: none; border: 0; color: var(--mint-500); }
.vswitch__arrow .icon { width: .3rem; height: .3rem; }
+1 -1
View File
@@ -257,7 +257,7 @@
.dpick__opt .icon { width: .2rem; height: .2rem; color: var(--d-color); opacity: .55; transition: opacity var(--t-fast); }
.dpick__opt:hover:not(:disabled) { color: var(--text-hi); background: rgba(255, 255, 255, .03); }
.dpick__opt:hover:not(:disabled) .icon { opacity: 1; }
.dpick__opt:disabled { cursor: progress; }
.dpick__opt:disabled { cursor: default; }
.dpick__opt.is-active {
color: var(--d-color);
background: transparent; background: color-mix(in srgb, var(--d-color) 17%, transparent);
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"maxHp":2738,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"maxHp":2680,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"maxHp":1787,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"maxHp":1208,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"maxHp":1472,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"maxHp":1184,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"maxHp":1215,"uid":8},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"maxHp":3430,"uid":101},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"maxHp":2698,"uid":102},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"maxHp":2767,"uid":103},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"maxHp":1470,"uid":104},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"maxHp":1361,"uid":105},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"maxHp":2887,"uid":106},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"maxHp":1569,"uid":107},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"maxHp":1237,"uid":108},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":3.99,"facing":1,"maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,3003,0.1,8,0,6],[2,8,2,2738,2738,0.1,8,0,6],[3,7,2,2680,2680,0.1,8,0,6],[4,4,3,1787,1787,0.1,6,0,6],[5,5,4,1208,1208,20.1,45,0,6],[6,6,5,1472,1472,16.1,34,0,6],[7,7,5,1184,1184,43.1,70,0,6],[8,8,5,1215,1215,0.1,35,0,6],[9,11,2,3430,3430,33.1,55,0,6],[10,12,2,2698,2698,15.1,55,0,6],[11,13,2,2767,2767,15.1,33,0,6],[12,16,3,1470,1470,29.1,42,0,6],[13,15,4,1361,1361,15.1,45,0,6],[14,14,5,2887,2887,20.1,34,0,6],[15,13,5,1569,1569,0.1,61,0,6],[16,12,5,1237,1237,30.1,75,0,6],[17,9.98,3.98,195000,195000,0,0,0,1],[18,10.01,3.99,195000,195000,0,0,0,1]],"dp":10,"killed":0,"total":11,"dps":{"P1":10,"P2":10},"boss":{"hp":195000,"max":195000}},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"gLine truncated
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
+12 -12
View File
@@ -8,7 +8,7 @@
"frames": 550,
"maxUnits": 30,
"file": "normal-m01.json",
"bytes": 416135,
"bytes": 417250,
"result": {
"reason": "timeout",
"killed": 14,
@@ -24,7 +24,7 @@
"frames": 850,
"maxUnits": 24,
"file": "normal-m02.json",
"bytes": 566914,
"bytes": 558479,
"result": {
"reason": "timeout",
"killed": 15,
@@ -40,7 +40,7 @@
"frames": 900,
"maxUnits": 31,
"file": "normal-m03.json",
"bytes": 719521,
"bytes": 720825,
"result": {
"reason": "timeout",
"killed": 11,
@@ -54,12 +54,12 @@
"stageId": "act2autochess_m04",
"duration": 70,
"frames": 700,
"maxUnits": 34,
"maxUnits": 33,
"file": "normal-m04.json",
"bytes": 557852,
"bytes": 560135,
"result": {
"reason": "timeout",
"killed": 19,
"killed": 18,
"total": 39
}
},
@@ -72,7 +72,7 @@
"frames": 843,
"maxUnits": 30,
"file": "late-m03-r12.json",
"bytes": 570239,
"bytes": 571183,
"result": {
"reason": "cleared",
"killed": 12,
@@ -88,7 +88,7 @@
"frames": 1100,
"maxUnits": 33,
"file": "late-m01-r13.json",
"bytes": 849162,
"bytes": 849895,
"result": {
"reason": "timeout",
"killed": 6,
@@ -104,7 +104,7 @@
"frames": 600,
"maxUnits": 36,
"file": "unite-m01.json",
"bytes": 619719,
"bytes": 620002,
"result": {
"reason": "timeout",
"killed": 17,
@@ -120,11 +120,11 @@
"frames": 750,
"maxUnits": 24,
"file": "boss-m02.json",
"bytes": 582523,
"bytes": 591355,
"result": {
"reason": "forced",
"killed": 10,
"total": 17
"killed": 12,
"total": 18
}
},
{
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_farttLine truncated
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":53,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,3513,3513,0,0,0,6],[3,5,9,2738,2738,0.1,8,0,6],[4,4,9,2680,2680,0.1,8,0,6],[5,4,12,1787,1787,0.1,6,0,6]Line truncated
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"maxHp":2216,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"maxHp":2389,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"maxHp":1123,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"maxHp":1260,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"maxHp":1400,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"maxHp":1472,"uid":7}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"maxHp":1400,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"maxHp":1472,"uid":7}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"maxHp":1145,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"maxHp":1123,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"maxHp":2389,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"maxHp":2216,"uid":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20.3,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.4,"units":[[1,9,9,2216,2216,0.4,47,0,6],[2,8,9,1285,1285,23.4,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.4,24,0,6],[5,4,11,1260,1260,0.4,60,0,6],[6,4,12,1400,1400,0.4,26,0,6],[7,5,12,1472,1472,20.4,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.5,"units":[[1,9,9,2216,2216,0.5,47,0,0],[2,8,9,1285,1285,23.5,40,0,0],[3,7,9,2389,2389,10,23,0,0Line truncated
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"maxHp":2022,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"maxHp":2938,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"maxHp":1428,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"maxHp":1271,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"maxHp":1246,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"maxHp":1288,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"maxHp":1217,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"maxHp":1246,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"maxHp":1288,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"maxHp":1217,"uid":8}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"maxHp":1271,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"maxHp":1428,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"maxHp":2938,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"goldLine truncated
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"maxHp":3003,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"maxHp":2250,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"maxHp":2216,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"maxHp":1208,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"maxHp":1206,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"maxHp":1184,"uid":7},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"maxHp":2775,"uid":8},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"maxHp":1208,"uid":5}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"maxHp":1206,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"maxHp":1184,"uid":7}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2Line truncated
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"maxHp":2173,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"maxHp":1481,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"maxHp":1911,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"maxHp":1404,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"maxHp":1364,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"maxHp":1232,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"maxHp":1251,"uid":7},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"maxHp":1404,"uid":4}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"maxHp":1364,"uid":5}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"maxHp":1232,"uid":6}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"maxHp":1251,"uid":7}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"maxHp":1911,"uid":3}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"maxHp":1481,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"maxHp":2173,"uid":1}],["deploy",1]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2173,2173,0.2,40,0,6],[2,8,9,1481,1481,6.2,21,0Line truncated
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1414,"uid":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"maxHp":1472,"uid":3},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"maxHp":1400,"uid":4},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"maxHp":1145,"uid":5},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"maxHp":2022,"uid":6},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"maxHp":2268,"uid":101},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"maxHp":1791,"uid":102},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"maxHp":1428,"uid":103},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"maxHp":1271,"uid":104},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"maxHp":1217,"uid":105},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"maxHp":1208,"uid":106}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"maxHp":1145,"uid":5}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"maxHp":1400,"uid":4}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"maxHp":1472,"uid":3}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"maxHp":2022,"uid":6}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"maxHp":1285,"uid":2}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"maxHp":2849,"uid":1}],["deploy",1],["spawn",{"id":11,"kind":"op","side":"ally","ownerId":Line truncated
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2}],["deploy",3],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2}],["deploy",6],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"cheLine truncated
+25 -4
View File
@@ -5,7 +5,8 @@
// through the same pushSnapshot/pushEvents path as the game, with play/pause, speed, scrub and scene select
// * stress scene: 120 units + projectiles / damage numbers every frame (FPS check)
// * fx scene: a small normal field (incl. art-less / unknown units, a crate and a turret device) cycling through
// every sim fx kind (render/fx.js FX_KINDS), every projectile kind and every status icon
// every sim fx kind (render/fx.js FX_KINDS), every projectile kind (boomerang included) and every status icon; at
// 'lock' it plays 蕾缪安's S3 as the sim does (a lock every 0.5 s, then one shell every 0.3 s with its bombard)
// Query: ?scene=prep|<recording>|stress|numbers &stage=<stageId> &t=<seconds> &speed=<x> &paused=1 &panel=0 &quality=high|medium|low
// &pen=<recording> (prep: that recording's round preview — m.private.nextEnemies — in the enemy preview pen)
// &fa=L|R (prep on that half of the Final Assault boss field, view.setCamera('bossPrep', { side }))
@@ -440,10 +441,11 @@ function fxScene(view, stageId) {
view.enterBattle({ fieldId: 'fx', kind: 'normal', rect, stageId, units });
view.setCamera('normal', { rect });
const kinds = Object.keys(FX_KINDS);
const projs = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'chain', 'chainHeal', 'beam', 'none'];
const projs = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'boomerang', 'chain', 'chainHeal', 'beam', 'none'];
const statuses = ['stun', 'ab:frost', 'reed2:scorch', 'fragile', 'sleep', 'silence', 'levitate', 'skill:shotst_shred', 'lumen:resist'];
let t = 0, acc = 0, i = 0;
const hp = new Map(units.map((u) => [u.id, u.maxHp]));
const unitAt = (id) => units.find((u) => u.id === id) || units[0];
const extraFor = (k) => {
const base = { id: [1, 20, 21, 3, 23][i % 5] };
switch (FX_KINDS[k].a) {
@@ -454,9 +456,26 @@ function fxScene(view, stageId) {
case 'zone': case 'telegraph': return { ...base, r: 1.5, dur: 2, tiles: k === 'telegraph' ? 'box' : undefined };
case 'dp': return { ...base, id: 3, n: 10 };
case 'element': return { ...base, id: 21, element: ['burn', 'neural', 'apoptosis'][i % 3] };
default: return base;
case 'shell': return { id: 2, r: 1.5, t: 0.3, i: 0 }; // 蕾缪安 (id = the shooter) shelling an enemy
default: return k === 'lock' ? { id: 21, src: 2 } : FX_KINDS[k].pt ? { id: 2, r: 1.5 } : base;
}
};
// `pt` kinds name the shooter in `id` and happen at an enemy's spot
const posFor = (k, ex) => unitAt(FX_KINDS[k].pt ? [21, 23, 20][i % 3] : ex.id);
// 蕾缪安 S3 as the sim plays it (sim/content/kits/tier6.js): a lock every 0.5 s, then after the skill one shell every
// 0.3 s on the locks in order, each landing 0.3 s later with its bombard — queued [game time, event]
const queue = [];
const lemuenS3 = () => {
const locks = [21, 23, 20, 21, 24];
locks.forEach((id, k) => queue.push([t + 0.5 * k, ['fx', 'lock', unitAt(id).x, unitAt(id).y, { id, src: 2 }]]));
const end = t + 0.5 * locks.length;
queue.push([end - 0.5, ['skill', 2, 0]]);
locks.forEach((id, k) => {
const x = unitAt(id).x + ((k * 37) % 7 - 3) * 0.05, y = unitAt(id).y + ((k * 53) % 7 - 3) * 0.05;
queue.push([end + 0.3 * k, ['fx', 'bombardShell', x, y, { id: 2, r: 1.5, t: 0.3, i: k }]]);
queue.push([end + 0.3 * k + 0.3, ['fx', 'bombard', x, y, { id: 2, r: 1.5 }]]);
});
};
return {
stageId, duration: 0, time: 0,
get kind() { return kinds[(i - 1 + kinds.length) % kinds.length]; },
@@ -467,10 +486,12 @@ function fxScene(view, stageId) {
acc -= 0.1;
t += 0.1;
const ev = [];
for (let q = queue.length - 1; q >= 0; q--) if (queue[q][0] <= t + 1e-6) ev.unshift(queue.splice(q, 1)[0][1]);
if (Math.round(t * 10) % 5 === 0) {
const k = kinds[i % kinds.length];
const ex = extraFor(k);
const at = units.find((u) => u.id === ex.id) || units[0];
const at = posFor(k, ex);
if (k === 'lock' && !queue.length) { ev.push(['skill', 2, 1]); lemuenS3(); }
ev.push(['fx', k, at.x, at.y, ex]);
$('title').textContent = `特效图鉴 · ${k} (${FX_KINDS[k].a})`;
const a = units[i % 4], b = units[4 + (i % 5)];
+109 -36
View File
@@ -1,5 +1,6 @@
// assets.js — asset manifest (/data/assets.json, docs/ASSETS.md) URL helpers, image cache/preload, and the
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer.
// Spine loader (LRU + refcount + memory budget + timeout + concurrency cap) used by the battlefield renderer. A
// skeleton is never handed out while its unload is in flight (RefLru; user playtest #3 item 1: invisible models).
//
// Pure at import time: no PIXI, no DOM access until a loader actually runs (Node tests import this file).
//
@@ -178,17 +179,25 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
/**
* LRU cache of refcounted async resources. `load(key, arg)` → Promise<value>; `unload(key, value, record)` frees it
* (`record` is the cache record the value belonged to).
* (`record` is the cache record the value belonged to) and may return a promise that settles once it is freed.
* Entries with refs > 0 are never evicted; the cache may exceed `max` while everything is in use.
* Failures are remembered for `failTtl` ms (so a missing model is not refetched every frame) and rethrown.
* Memory budget (optional): `weigh(key, value, arg)` → cost of a ready value; idle (refs 0) ready entries are evicted,
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` while nothing at all
* is referenced (no scene on screen) — once they have been idle for `idleGrace` ms (a scene switch releases and
* re-acquires its models within that window, so it never reloads them); a timer sweeps what the grace held back.
* least recently used first, while their total weight exceeds `maxIdleWeight` — or `quietWeight` once nothing at all
* has been referenced for `quietDelay` ms (no scene on screen; the instant zero in the middle of a scene switch is not
* quiet) — once they have been idle for `idleGrace` ms (a scene switch releases and re-acquires its models within that
* window, so it never reloads them); a timer sweeps what the grace held back.
* `evictDelay` > 0: a release (or a finished load) schedules the eviction pass that much later instead of running it
* at once, so a scene rebuilt in one go (every view destroyed, then the next scene's views built) never drops a value
* it takes again.
* A value whose unload is still in flight is never handed out again: a new load of that key waits for the unload to
* settle (user playtest #3 item 1 — PIXI.Assets keeps an unloading skeleton in its loader cache until a microtask
* later, so loading the same URL meanwhile returned the doomed skeleton, whose textures the unload then destroyed:
* invisible operator models).
*/
export class RefLru {
constructor({ load, unload = () => {}, max = 60, timeout = 20000, concurrency = 6, failTtl = 60000, now = () => Date.now(),
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, timers } = {}) {
weigh = null, maxIdleWeight = Infinity, quietWeight, idleGrace = 0, quietDelay = 0, evictDelay = 0, timers } = {}) {
if (typeof load !== 'function') throw new TypeError('RefLru: load required');
this._load = load;
this._unload = unload;
@@ -202,13 +211,19 @@ export class RefLru {
this.maxIdleWeight = budget(maxIdleWeight, Infinity);
this.quietWeight = budget(quietWeight, this.maxIdleWeight);
this.idleGrace = budget(idleGrace, 0);
this.quietDelay = budget(quietDelay, 0);
this.evictDelay = budget(evictDelay, 0);
this._timers = timers && typeof timers.set === 'function' ? timers : {
set: (fn, ms) => { const t = setTimeout(fn, ms); t?.unref?.(); return t; },
clear: (t) => clearTimeout(t),
};
this._sweep = null; // { timer, at } pending weight sweep
this._sweep = null; // { timer, at } pending eviction pass (weight sweep / deferred eviction)
/** @type {Map<string, { key, promise, value, state: 'loading'|'ready'|'failed', refs: number, used: number, weight: number, idleSince: number|null, error?, failedAt? }>} */
this.map = new Map();
this._refs = 0; // Σ refs of every entry
this._quietAt = this.now(); // since when nothing has been referenced (null while something is)
/** @type {Map<string, Promise<void>>} key → unload still in flight (settles, never rejects) */
this._unloading = new Map();
this._active = 0;
this._queue = [];
this._tick = 0;
@@ -230,12 +245,17 @@ export class RefLru {
if (!e) {
e = { key, promise: null, value: null, state: 'loading', refs: 0, used: ++this._tick, weight: 0, idleSince: null };
this.map.set(key, e);
e.promise = this._schedule(() => this._withTimeout(this._load(key, arg), key)).then(
// an unload of this key still in flight: load again only once it has settled (never the doomed value)
const load = () => {
const pending = this._unloading.get(key);
return pending ? pending.then(() => this._load(key, arg)) : this._load(key, arg);
};
e.promise = this._schedule(() => this._withTimeout(load(), key)).then(
(v) => {
if (this.map.get(key) !== e) { try { this._unload(key, v, e); } catch { /* ignore */ } return v; }
if (this.map.get(key) !== e) { this._unloadNow(key, v, e); return v; }
e.value = v; e.state = 'ready';
if (this._weigh) { let w = 0; try { w = Number(this._weigh(key, v, arg)); } catch { /* ignore */ } e.weight = w > 0 ? w : 0; }
this._evict();
this._requestEvict();
return v;
},
(err) => {
@@ -246,6 +266,8 @@ export class RefLru {
e.promise.catch(() => {});
}
e.refs++;
this._refs++;
this._quietAt = null;
e.idleSince = null;
e.used = ++this._tick;
return e.promise;
@@ -255,14 +277,27 @@ export class RefLru {
release(key) {
const e = this.map.get(key);
if (!e) return;
if (e.refs > 0 && --e.refs === 0) e.idleSince = this.now();
if (e.refs > 0) {
if (--e.refs === 0) e.idleSince = this.now();
if (--this._refs <= 0) { this._refs = 0; this._quietAt = this.now(); }
}
e.used = ++this._tick;
this._evict();
this._requestEvict();
}
/** Is an unload of `key` still in flight (a new load of it waits for it)? */
unloading(key) { return this._unloading.has(key); }
/** Evict now, or (evictDelay > 0) in one pass a moment later. */
_requestEvict() {
if (this.evictDelay > 0) this._sweepIn(this.evictDelay);
else this._evict();
}
/**
* Evict least-recently-used unreferenced ready/failed entries above `max`, then idle ready entries past their
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` when nothing is referenced).
* grace while the idle weight is over budget (`maxIdleWeight`, or `quietWeight` once nothing has been referenced
* for `quietDelay` ms).
*/
_evict() {
if (this.map.size > this.max) {
@@ -273,33 +308,49 @@ export class RefLru {
}
}
if (!this._weigh) return;
let refs = 0, idleWeight = 0;
let idleWeight = 0;
const idle = [];
for (const e of this.map.values()) {
refs += e.refs;
if (e.refs === 0 && e.state === 'ready') { idleWeight += e.weight; idle.push(e); }
}
const budget = refs === 0 ? this.quietWeight : this.maxIdleWeight;
if (idleWeight <= budget) return;
idle.sort((a, b) => a.used - b.used);
const now = this.now();
let budget = this.maxIdleWeight;
let wait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { wait = Math.min(wait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
if (this._refs === 0) {
const quietFor = now - (this._quietAt ?? now);
if (quietFor >= this.quietDelay) budget = this.quietWeight;
else if (idleWeight > this.quietWeight) wait = this.quietDelay - quietFor; // re-check once the quiet has lasted
}
if (idleWeight > budget && wait < Infinity) this._sweepIn(wait);
if (idleWeight > budget) {
idle.sort((a, b) => a.used - b.used);
let graceWait = Infinity;
for (const e of idle) {
if (idleWeight <= budget) break;
const age = now - (e.idleSince ?? now);
if (age < this.idleGrace) { graceWait = Math.min(graceWait, this.idleGrace - age); continue; }
idleWeight -= e.weight;
this._drop(e);
}
if (idleWeight > budget) wait = Math.min(wait, graceWait);
}
if (wait < Infinity) this._sweepIn(wait);
}
_drop(e) {
this.map.delete(e.key);
if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
}
/** Re-run eviction in `ms` (keeps the earliest pending sweep). */
/** Run the unload of a value; while a returned promise is pending, loads of that key wait (see the header). */
_unloadNow(key, value, rec) {
let p = null;
try { p = this._unload(key, value, rec); } catch { p = null; }
if (!p || typeof p.then !== 'function') return;
const done = Promise.resolve(p).then(() => {}, () => {}).then(() => { if (this._unloading.get(key) === done) this._unloading.delete(key); });
this._unloading.set(key, done);
}
/** Re-run eviction in `ms` (keeps the earliest pending pass). */
_sweepIn(ms) {
const at = this.now() + ms;
if (this._sweep && this._sweep.at <= at) return;
@@ -313,7 +364,9 @@ export class RefLru {
if (this._sweep) { this._timers.clear(this._sweep.timer); this._sweep = null; }
const all = [...this.map.values()];
this.map.clear();
for (const e of all) if (e.state === 'ready') { try { this._unload(e.key, e.value, e); } catch { /* ignore */ } }
this._refs = 0;
this._quietAt = this.now();
for (const e of all) if (e.state === 'ready') this._unloadNow(e.key, e.value, e);
}
stats() {
@@ -324,7 +377,7 @@ export class RefLru {
weight += e.weight || 0;
if (e.refs === 0) idleWeight += e.weight || 0;
}
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length };
return { size: this.map.size, ready, loading, failed, refs, weight, idleWeight, active: this._active, queued: this._queue.length, unloading: this._unloading.size };
}
_schedule(fn) {
@@ -434,26 +487,32 @@ export function spinePages(entry) {
* parser loads each page through the loader (`loader.load({ src })`), so the decoded page (an ImageBitmap) stays in
* `PIXI.Assets.loader.promiseCache` until that URL is unloaded too. Pages listed in `keep` (still used by another
* cached skeleton) are left alone.
* The unloads are asynchronous (PIXI frees an asset a microtask after the call, and until then `PIXI.Assets.load` of
* the same URL hands out that doomed asset): the returned promise settles (never rejects) once every one has run, and
* the spine LRU makes a new load of the skeleton wait for it (RefLru header).
* @param {object} entry manifest spine entry { skel, atlas?, textures? }
* @param {any} [_value] the unloaded spineData (unused)
* @param {Set<string>} [keep] page URLs not to unload
* @returns {Promise<void>|undefined}
*/
export function unloadSpineData(entry, _value, keep) {
const PIXI = globalThis.PIXI;
const skel = entry && entry.skel;
if (!skel || !PIXI?.Assets?.unload) return;
if (!skel || !PIXI?.Assets?.unload) return undefined;
const atlas = (typeof entry.atlas === 'string' && entry.atlas) || skel.replace(/\.skel$/, '.atlas');
const loader = PIXI.Assets.loader;
const pending = [];
const unload = (url, direct) => {
try {
const p = direct && loader && typeof loader.unload === 'function' ? loader.unload(url) : PIXI.Assets.unload(url);
Promise.resolve(p).catch(() => {});
pending.push(Promise.resolve(p).catch(() => {}));
} catch { /* ignore */ }
};
unload(skel, false);
unload(atlas, false);
// pages were loaded by the atlas parser straight through the loader (never through Assets.load / the resolver)
for (const url of spinePages(entry)) if (!(keep && keep.has(url))) unload(url, true);
return Promise.all(pending).then(() => {});
}
// ---- store ---------------------------------------------------------------------------------------------------
@@ -462,13 +521,22 @@ export function unloadSpineData(entry, _value, keep) {
export const SPINE_IDLE_BYTES = 48 * 1024 * 1024;
/** How long a released skeleton is safe from the weight budget (a prep ⇄ battle switch re-acquires within it). */
export const SPINE_IDLE_GRACE_MS = 15000;
/**
* Spine LRU eviction runs this long after a release, in one pass: a scene switch (battle → prep destroys every battle
* view, then builds the prep views; prep → battle builds the battle views over the next frames) takes its models back
* before anything is dropped (user playtest #3 item 1: the bench models were unloaded and re-loaded in one go).
*/
export const SPINE_EVICT_DELAY_MS = 1000;
/** Nothing referenced for this long = no scene on screen (lobby / room): the idle skeletons may all go. */
export const SPINE_QUIET_DELAY_MS = 3000;
/**
* Create an asset store.
* @param {{ url?: string, fetch?: typeof fetch, manifest?: object, loadImage?: (url) => Promise<any>,
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => void, spineMax?: number,
* spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
* loadSpine?: (entry) => Promise<any>, unloadSpine?: (entry, value, keepPages:Set<string>) => (Promise<void>|void),
* spineMax?: number, spineTimeout?: number, spineWeigh?: (key, spineData) => number, spineIdleBytes?: number,
* spineQuietBytes?: number, spineIdleGrace?: number, spineEvictDelay?: number, spineQuietDelay?: number,
* spineTimers?: { set, clear }, spineNow?: () => number }} [opts]
*/
export function createAssets(options) {
const opts = options && typeof options === 'object' ? options : {};
@@ -512,17 +580,22 @@ export function createAssets(options) {
spineEntries.delete(key);
const keep = new Set();
for (const [k, other] of spineEntries) if (k !== key && spine.map.has(k)) for (const u of spinePages(other)) keep.add(u);
(opts.unloadSpine || unloadSpineData)(e, value, keep);
// the promise (when the unloader returns one) holds back a new load of this skeleton until it has settled
return (opts.unloadSpine || unloadSpineData)(e, value, keep);
},
max: opts.spineMax ?? 60,
timeout: opts.spineTimeout ?? 20000,
concurrency: opts.spineConcurrency ?? 6,
// memory budget (a parsed skeleton holds ~0.1–12 MB): idle skeletons beyond SPINE_IDLE_BYTES go, and all of
// them once no scene references any (lobby / room), each after a grace that covers a prep ⇄ battle switch
// them once no scene has referenced any for SPINE_QUIET_DELAY_MS (lobby / room), each after a grace that covers a
// prep ⇄ battle switch; evictions run SPINE_EVICT_DELAY_MS after the release that allows them (a scene switch
// takes its models back first)
weigh: opts.spineWeigh || ((key, value) => spineDataWeight(value)),
maxIdleWeight: opts.spineIdleBytes ?? SPINE_IDLE_BYTES,
quietWeight: opts.spineQuietBytes ?? 0,
idleGrace: opts.spineIdleGrace ?? SPINE_IDLE_GRACE_MS,
quietDelay: opts.spineQuietDelay ?? SPINE_QUIET_DELAY_MS,
evictDelay: opts.spineEvictDelay ?? SPINE_EVICT_DELAY_MS,
timers: opts.spineTimers,
now: opts.spineNow,
});
+46 -3
View File
@@ -20,13 +20,17 @@
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
// Solo pause (g.pause, DESIGN §14): while `m.public.paused` is true every local battle clock stands still (no ticks, no
// reports); on resume the clocks move on by the paused time, like the server's field clock.
// Live leaks (user playtest #3 item 2): every normal field simulated here keeps its counted leaks so far — the settle
// rule's count (leaked entries with counted !== false, /sim/spec.js battleProgress) — and publishes them as
// state().leaks { [fieldId]: n } whenever one changes, authoritative or display replica alike; the top bar shows the
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp).
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused } | null
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
@@ -133,6 +137,8 @@ export function createBattleRunner(deps) {
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
/** a normal field's leak count changed since the last publishState() */
let leaksDirty = false;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
const hidden = () => !!(doc && doc.hidden);
@@ -147,21 +153,48 @@ export function createBattleRunner(deps) {
return simP;
}
/** Counted leaks so far of every normal field simulated here: { [fieldId]: n } (user playtest #3 item 2). */
function leakMap() {
const out = {};
for (const e of entries.values()) if (e.kind === 'normal' && e.fieldId) out[e.fieldId] = e.leaks;
return out;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap() } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
leaks: leakMap(),
};
}
function publishState() {
leaksDirty = false;
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/**
* Re-count an entry's leaks when its battle recorded a new one (Battle.leakedCount) or ended (timeout leaks, and the
* final result): the settle rule's count, leaked entries with counted !== false (/sim/spec.js battleProgress).
*/
function noteLeaks(e) {
if (e.kind !== 'normal') return;
const b = e.battle;
const mark = `${Number(b.leakedCount) || 0}:${b.finished ? 1 : 0}`;
if (mark === e.leakMark) return;
e.leakMark = mark;
let n = e.leaks;
try { n = Math.max(0, Math.trunc(Number(e.sim.spec.battleProgress(b).leaks) || 0)); } catch { /* keep the last count */ }
if (n !== e.leaks) { e.leaks = n; leaksDirty = true; }
}
/** Publish the state when a leak count changed since the last publish. */
function flushLeaks() { if (leaksDirty) publishState(); }
/** Target tick of an entry on its clock. */
const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9));
@@ -225,6 +258,7 @@ export function createBattleRunner(deps) {
function finished(e) {
if (e.done) return;
e.done = true;
noteLeaks(e);
if (e.authoritative && !e.resultSent && net) {
progress(e, true);
e.resultSent = true;
@@ -240,6 +274,7 @@ export function createBattleRunner(deps) {
}
}
if (e === cur) publishState();
else flushLeaks();
}
/**
@@ -302,6 +337,7 @@ export function createBattleRunner(deps) {
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
noteLeaks(e);
progress(e);
if (e.battle.finished) finished(e);
}
@@ -313,6 +349,7 @@ export function createBattleRunner(deps) {
stats.frames++;
const t = clock();
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
flushLeaks();
schedule();
}
@@ -320,6 +357,7 @@ export function createBattleRunner(deps) {
// hidden tab: no animation frames — keep authoritative battles on their clock (no rendering)
if (!hidden()) return;
for (const e of [...entries.values()]) if (e.authoritative && running(e)) advance(e, clock(), false);
flushLeaks();
}
function schedule() {
@@ -411,6 +449,8 @@ export function createBattleRunner(deps) {
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at
leaks: 0, leakMark: '',
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
@@ -420,17 +460,19 @@ export function createBattleRunner(deps) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
noteLeaks(e);
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); schedule(); }
if (e.authoritative) { entries.set(e.battleId, e); evict(); if (e.leaks) leaksDirty = true; schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
noteLeaks(e);
show(e);
if (battle.finished) finished(e);
}
@@ -459,6 +501,7 @@ export function createBattleRunner(deps) {
}
if (e === cur) emitFrame(e, false);
finished(e);
flushLeaks();
schedule();
}
+50 -16
View File
@@ -1,8 +1,12 @@
// Lazy loader + cache for the generated game data served at /data/*.json.
//
// Files are fetched on first use and cached (the Promise is shared, so concurrent callers trigger a
// single request). Missing files (404, network failure, bad JSON) resolve to `null` and are
// reported once on the console — the UI must degrade gracefully while data is being generated.
// Files are fetched on first use and cached for the page's lifetime (the Promise is shared, so concurrent callers
// trigger a single request): every text of the game (operators, skills, bonds, items, enemies …) is static data loaded
// once — nothing is fetched from the server during a match. main.js warms the in-match files (gameComponents
// GAME_FILES) in the background once the player is in a room, and the match screen waits for them, so no text of the
// match UI ever appears late. A transient failure (network error, HTTP 5xx) is retried twice (RETRY_DELAYS_MS); files
// that stay unavailable (404, repeated failures, bad JSON) resolve to `null` and are reported once on the console — the
// UI must degrade gracefully while data is being generated.
//
// Each file is indexed tolerantly so the getters work whether a file is
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
@@ -70,13 +74,31 @@ export function buildIndex(name, json) {
return map;
}
/** Waits (ms) before retrying a data file whose download failed transiently (network error, HTTP 5xx / 408 / 429). */
export const RETRY_DELAYS_MS = Object.freeze([600, 2000]);
/**
* A failed load worth retrying: the request itself failed (network error) or the server answered 5xx / 408 / 429 — not a
* definite 4xx (the file is not there) nor a delivered file that is not valid JSON (`badJson`).
*/
const transientFailure = (err) => {
if (!err || err.badJson) return false;
const s = err.status;
return !(Number.isInteger(s) && s >= 400 && s < 500 && s !== 408 && s !== 429);
};
/**
* Create a data store bound to a fetch implementation (injectable for tests).
* @param {{ fetch?: typeof fetch, base?: string }} [opts]
* A file is downloaded once per page (the texts of the game are static data, never fetched again during a match —
* user playtest #3 item 9); a transient failure is retried (RETRY_DELAYS_MS) while the file stays 'loading', so a
* network hiccup does not leave the texts of a whole session missing.
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void> }} [opts]
*/
export function createDataStore(opts = {}) {
const base = opts.base ?? '/data/';
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : RETRY_DELAYS_MS;
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
const entries = new Map();
const listeners = new Set();
@@ -96,19 +118,31 @@ export function createDataStore(opts = {}) {
if (cur) return cur.promise;
const entry = { status: 'loading', promise: null, value: null, index: null };
entry.promise = (async () => {
try {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '???'}`);
const json = await res.json();
entry.value = json;
entry.status = 'ready';
} catch (err) {
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
for (let attempt = 0; ; attempt++) {
try {
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
let json;
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
entry.value = json;
entry.status = 'ready';
break;
} catch (err) {
// a transient failure is tried again (still 'loading'), unless the load was superseded meanwhile
if (transientFailure(err) && attempt < retryDelays.length && entries.get(name) === entry) {
await wait(retryDelays[attempt]);
if (entries.get(name) === entry) continue;
entry.status = 'missing'; // superseded by invalidate() meanwhile: the new load reports for itself
break;
}
if (!warned.has(name)) {
warned.add(name);
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
}
entry.value = null;
entry.status = 'missing';
break;
}
entry.value = null;
entry.status = 'missing';
}
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
if (entries.get(name) === entry) notify(name);
+18
View File
@@ -21,6 +21,8 @@
// re-layout; ui/compat.js polyfills are imported before anything else.
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
import './ui/compat.js';
@@ -32,6 +34,7 @@ import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.js';
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js';
import { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
@@ -223,9 +226,24 @@ function wireNet() {
// Entering (title → lobby) while already online also needs the deep-link join.
store.subscribe((s, prev) => {
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
// in a room (co-op or solo, also a resumed one) a match is near: its data starts downloading
if (s.room && !prev.room) warmGameData();
});
}
/**
* Download every data file of the match UI (gameComponents GAME_FILES: operators, skills, bonds, items, enemies, 特质 …)
* in the background once the player is in a room — a match is near (the lobby alone never downloads them). The game's
* texts are static data loaded once per page — never fetched during a match — and the match screen waits for these
* files, so with them warmed it opens at once and no text ever appears late (user playtest #3 item 9). Idempotent (the
* data store shares each file's promise).
*/
function warmGameData() {
const go = () => { data.loadAll(GAME_FILES).catch(() => {}); };
if (typeof globalThis.requestIdleCallback === 'function') globalThis.requestIdleCallback(go, { timeout: 2500 });
else setTimeout(go, 600);
}
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
function ScreenCrashed({ error, reset }) {
+154 -38
View File
@@ -14,11 +14,22 @@
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over}
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
// tileHover { row, col, area, idx } | null
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px) | null
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null;
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt)
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the
// unit standing on the pressed tile. A dragged
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
// and the drop target is the tile under it (render/drag.js dropPoint).
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -84,6 +95,7 @@ import { BoardScene } from './board3d/scene.js';
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickBody } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -100,6 +112,21 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/** A touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */
export const TOUCH_LIFT_TILES = 0.6;
/** The display tile a view stands on (its drawn position). */
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) });
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */
function pickShapeOf(v, tile) {
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null;
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
const b = v.pickShape();
if (!b) return null;
b.tile = tile === undefined ? viewTile(v) : tile;
b.ref = v;
return b;
}
let pixiPromise = null;
@@ -894,16 +921,13 @@ export async function createFieldView(host, options = {}) {
penList = null;
}
/** Front-most pen enemy under a canvas point. */
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
function penUnitAt(x, y) {
if (penHidden) return null;
let best = null, bestZ = -Infinity;
for (const v of penViews.values()) {
if (!v.hitTest(x, y)) continue;
const z = v.root ? v.root.zIndex : 0;
if (z > bestZ) { bestZ = z; best = v; }
}
return best;
const bodies = [];
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); }
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y));
return hit ? hit.ref : null;
}
function emitPenClick(v, e) {
@@ -913,27 +937,100 @@ export async function createFieldView(host, options = {}) {
// ---- drag & pointer ----------------------------------------------------------------------------------------
function pieceAt(x, y) {
if (mode !== 'prep') return null;
let best = null, bestZ = -Infinity;
/** The display tile under a canvas point (raised tops first), or null off the grid. */
function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col } : null;
}
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel
let probeRT = null;
let probeM = null;
/**
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
*/
function drawnAt(v, x, y) {
const root = v && v.root;
if (!root || root.destroyed) return false;
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
try {
const R = app.renderer;
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
if (!probeM) probeM = new P.Matrix();
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
const px = R.extract.pixels(probeRT);
return px && px.length >= 4 ? px[3] > 40 : null;
} catch { return null; }
}
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
// alone (it only moves a highlight). Presses, releases and drops always probe.
let hoverProbeFrame = -1;
const hoverProbeAt = (x, y) => {
if (hoverProbeFrame === frameNo) return null;
hoverProbeFrame = frameNo;
return probeAt(x, y);
};
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
function prepEntryAt(x, y, accept = null, hover = false) {
const bodies = [];
for (const e of prepPieces) {
const v = views.get(e.key);
if (!v || !v.hitTest(x, y)) continue;
const z = v.root.zIndex;
if (z > bestZ) { bestZ = z; best = e; }
}
if (!best) {
// fall back to the tile under the pointer
const t = pickBoardTile(x, y);
if (t) best = prepPieces.find((e) => { const w = pieceTile(e); return w && w.row === t.row && w.col === t.col; }) || null;
if (accept && !accept(e)) continue;
const b = pickShapeOf(views.get(e.key));
if (b) { b.entry = e; bodies.push(b); }
}
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
return hit ? hit.entry : null;
}
function pieceAt(x, y, hover = false) {
if (mode !== 'prep') return null;
const best = prepEntryAt(x, y, null, hover);
if (!best) return null;
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
}
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
function pieceUnder(x, y, exceptUid, hover = false) {
if (mode !== 'prep') return null;
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
return e ? e.uid : null;
}
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
let dragHit = null;
/**
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
* (a move is a hover pick; the release (`final`) always probes).
*/
function dropPoint(e, piece, final = false) {
let x = e.x, y = e.y;
if (e.pointerType === 'touch') {
const g = cam.unproject(x, y, 0);
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
}
const uid = piece ? piece.uid : null;
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
dragHit = { uid, over, x, y };
if (changed && piece && piece.kind === 'item') {
const r = canvas.getBoundingClientRect();
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
}
return { x, y };
}
const drag = createDragController({
hitPiece: (x, y) => pieceAt(x, y),
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
pickTile: (x, y) => pickBoardTile(x, y),
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
isOverCanvas: (cx, cy) => {
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
},
@@ -947,8 +1044,10 @@ export async function createFieldView(host, options = {}) {
function onDragEvent(name, payload) {
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
switch (name) {
case 'pieceDragStart': startDragVisual(payload); break;
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break;
case 'pieceDragMove': moveDragVisual(payload); return; // internal
case 'pieceDrop': dropDragVisual(payload); break;
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
@@ -1012,12 +1111,15 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
const zPlane = 0.1;
const w = cam.unproject(p.x, p.y + cam.scaleAt(v.x, v.y, 0) * 0.45, zPlane);
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y;
const t = pickTile(cam, hx, hy, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const w = cam.unproject(hx, hy, z);
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = zPlane;
v.z = z;
}
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
@@ -1065,15 +1167,16 @@ export async function createFieldView(host, options = {}) {
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
}
function battleUnitAt(x, y) {
let best = null, bestZ = -Infinity;
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */
function battleUnitAt(x, y, hover = false) {
const bodies = [];
for (const v of views.values()) {
if (!v.hitTest || !v.alive || !v.info || v.info.kind === 'device') continue;
if (!v.hitTest(x, y)) continue;
const z = v.root ? v.root.zIndex : 0;
if (z > bestZ) { bestZ = z; best = v; }
if (!v.info || v.info.kind === 'device') continue;
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
if (b) bodies.push(b);
}
return best;
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
return hit ? hit.ref : null;
}
const onPointerDown = (e) => {
@@ -1103,7 +1206,7 @@ export async function createFieldView(host, options = {}) {
const ev = evPayload(e);
if (mode === 'battle') {
if (e.pointerType === 'touch') return;
const v = battleUnitAt(ev.x, ev.y);
const v = battleUnitAt(ev.x, ev.y, true);
if (v !== hoverUnit) {
hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null;
@@ -1551,7 +1654,14 @@ export async function createFieldView(host, options = {}) {
const b = v.bounds();
const r = canvas.getBoundingClientRect();
const left = r.left + b.x, top = r.top + b.y;
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt)
let shape = null;
if (typeof v.pickShape === 'function') {
const s = v.pickShape();
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
}
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
},
/**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
@@ -1636,6 +1746,8 @@ export async function createFieldView(host, options = {}) {
try { fx.destroy(); } catch { /* ignore */ }
try { tiles.destroy(); } catch { /* ignore */ }
try { impostors.destroy(); } catch { /* ignore */ }
try { probeRT?.destroy(true); } catch { /* ignore */ }
probeRT = null;
listeners.clear();
try { board3d?.destroy(); } catch { /* ignore */ }
board3d = null;
@@ -1654,7 +1766,11 @@ export async function createFieldView(host, options = {}) {
},
get mode() { return mode; },
/** Dev hooks (demo / tests). */
debug: { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); } },
debug: {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; },
},
};
return view;
}
+31 -10
View File
@@ -9,14 +9,20 @@
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and
// presses use the pointer.
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
// tileHover { row, col, area, idx } | null (hover / drag tile changes)
// tileHover { row, col, area, idx } | null (hover / drag target tile changes)
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
// pieceDragStart { uid, piece, from }
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
// hx, hy = the drop point)
// pieceDrop { uid, piece, from, target }
// pieceDragEnd { uid, piece, dropped, cancelled }
@@ -90,8 +96,11 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
/**
* Pointer state machine.
* @param {{
* hitPiece: (x, y) => (object|null), // piece under canvas point (front-most); piece.uid required
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required;
* // hover = a mouse move (not a press): may skip the pixel probe
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
* // final = the release (a move may skip the pixel probe)
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
* canPlace?: (piece, row, col, target) => boolean,
* emit: (name, payload) => void,
@@ -112,8 +121,14 @@ export function createDragController(hooks) {
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } };
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
const dropAt = (e, piece, final = false) => {
let p = null;
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
};
function setHoverTile(tile) {
const slot = tile ? tileSlot(tile) : null;
@@ -140,13 +155,18 @@ export function createDragController(hooks) {
moveDrag(e);
}
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
function moveDrag(e) {
const tile = tileAt(e.x, e.y);
const over = overCanvas(e.clientX, e.clientY);
const pt = dropAt(e, st.piece);
const tile = tileAt(pt.x, pt.y);
const over = dropOverCanvas(e, pt);
const slot = over && tile ? tileSlot(tile) : null;
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
setHoverTile(over ? tile : null);
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
}
function endDrag(e, cancelled) {
@@ -155,8 +175,9 @@ export function createDragController(hooks) {
if (!s) return;
let dropped = false;
if (!cancelled) {
const tile = tileAt(e.x, e.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
const pt = dropAt(e, s.piece, true);
const tile = tileAt(pt.x, pt.y);
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), clientX: e.clientX, clientY: e.clientY, canPlace });
if (d.kind === 'drop') {
dropped = true;
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
@@ -205,7 +226,7 @@ export function createDragController(hooks) {
pointerMove(e) {
if (!st) {
if (e.pointerType === 'touch') return;
setHoverPiece(pieceAt(e.x, e.y), e);
setHoverPiece(pieceAt(e.x, e.y, true), e);
setHoverTile(tileAt(e.x, e.y));
return;
}
+821 -152
View File
File diff suppressed because it is too large. Load diff
+224
View File
@@ -0,0 +1,224 @@
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt).
//
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile,
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its
// feet or on its tile went to the unit in front of it: the one a row lower on screen.
//
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair,
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
// bars) — what is drawn
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
// 0.1 above the feet to the shoulders) — what a player aims at
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
// Rule (pickBody):
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
// the torso of the unit behind is its own above that face)
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
// weapon of the unit in front hangs over it)
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
// own scale); ties → the front-most
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
// tile beside that face and everything of it above that face are.
//
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
// cannot tell (null) does the rule above decide.
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */
export const BODY_H = 1.27;
/** Shape constants in tiles (see the header). */
export const BODY = Object.freeze({
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
});
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1;
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1;
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1));
function ellipseDist(e, px, py) {
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry);
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0;
}
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k;
/**
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate',
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
* tile?: { row: number, col: number } | null }} PickBody
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
*/
/**
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
* @param {PickBody} b
*/
export function bodyParts(b) {
const s = b.s, x = b.x, y = b.y;
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
if (b.kind === 'plate') {
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
}
if (b.kind === 'diamond') {
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
}
const w = Number(b.w) > 0 ? b.w : 1;
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
const flip = b.flip === -1 ? -1 : 1;
const cx = x - BODY.lean * flip * w * s;
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
}
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
function partDist(p, px, py) {
if (p.rect) return rectDist(p.rect, px, py);
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
}
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
export function hitBody(b, px, py) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
}
const parts = bodyParts(b);
if (parts.core.some((p) => partHit(p, px, py))) return 2;
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
}
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
export function coreDistance(b, px, py) {
const parts = bodyParts(b);
let d = Infinity;
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
return d / b.s;
}
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
export function bodyBounds(b) {
const parts = bodyParts(b);
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of [...parts.outline, ...parts.core]) {
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
}
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
export const PROBE_MARGIN = 0.5;
/** At most this many overlapping bodies are probed (front-most first). */
const PROBE_MAX = 6;
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
export function probeCandidates(bodies, px, py) {
const out = [];
for (const b of bodies) {
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
}
return out.sort((a, b) => depthOf(b) - depthOf(a));
}
/**
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one
* in front of the first that does (then the shape rule decides).
*/
function probeBodies(bodies, px, py, probe, rule) {
const cand = probeCandidates(bodies, px, py);
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null;
for (const b of cand.slice(0, PROBE_MAX)) {
let d = null;
try { d = probe(b); } catch { d = null; }
if (d === true) return b;
if (d !== false) return null;
}
return null;
}
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
function nearest(list, px, py) {
let best = null, bd = Infinity;
for (const b of list) {
const d = coreDistance(b, px, py);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; }
}
return best;
}
/**
* The body the pointer is on (see the header for the rule), or null.
* @param {Array<PickBody|null>} bodies
* @param {number} px
* @param {number} py
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`)
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
* (null: cannot tell)? See the header.
* @returns {PickBody|null} one of `bodies`
*/
export function pickBody(bodies, px, py, ground = null, probe = null) {
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null;
const rule = ruleBody(bodies, px, py, ground);
if (typeof probe === 'function') {
const drawn = probeBodies(bodies, px, py, probe, rule);
if (drawn) return drawn;
}
return rule;
}
/** The shape rule of pickBody (the header's steps 1–3). */
function ruleBody(bodies, px, py, ground) {
let core = null;
const rims = [];
for (const b of bodies) {
const hit = hitBody(b, px, py);
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
else if (hit === 1) rims.push(b);
}
if (core) return core;
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
if (standing.length) return nearest(standing, px, py);
return rims.length ? nearest(rims, px, py) : null;
}
+22 -8
View File
@@ -88,15 +88,29 @@ export function dmgStyleKey(type) {
/** Hit spark colour per damage type. */
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/** Projectile visuals per atk projKind (sim: none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal). */
/**
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
* speed tiles per game second — a copy of the sim's PROJECTILE_SPEEDS (server/sim/constants.js; `back` of the
* boomerang = BOOMERANG_RETURN_SPEED, PRTS 跃跃: out 15, back 3.75). fx.js prefers the sim's own values, loaded
* from /sim/constants.js, so the visual flight ends with the sim's hit; this copy stands in until then (and in
* Node). test/render/fxproj.test.js keeps the two equal.
* sizes in tiles (× the camera's px per tile at the shot): `len` trail length, `width` trail thickness, `head` glow
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
*/
export const PROJ = Object.freeze({
arrow: { speed: 14, tex: 'streak', tint: 0xfff2d0, len: 0.55, width: 0.09, trail: 0 },
bolt: { speed: 11, tex: 'orb', tint: 0xd49bff, len: 0.28, width: 0.28, trail: 0xa35cff },
orb: { speed: 11, tex: 'orb', tint: 0x9dffb8, len: 0.26, width: 0.26, trail: 0x4ee07a },
bomb: { speed: 8, tex: 'orb', tint: 0xffb066, len: 0.24, width: 0.24, trail: 0xff7a33, arc: 0.9 },
lob: { speed: 8, tex: 'orb', tint: 0xffc78a, len: 0.22, width: 0.22, trail: 0xffa04a, arc: 1.2 },
drone: { speed: 16, tex: 'orb', tint: 0x8fe6ff, len: 0.16, width: 0.16, trail: 0x57c9ff },
enemy: { speed: 12, tex: 'orb', tint: 0xff6a5a, len: 0.22, width: 0.22, trail: 0xff3b30 },
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
bolt: { look: 'orb', speed: 11, tint: 0xf4e2ff, glow: 0xb36bff, len: 0.6, width: 0.34, head: 0.52, trail: 0xa35cff, muzzle: 0xc77dff, hit: 'arts' },
orb: { look: 'orb', speed: 10, tint: 0xeafff0, glow: 0x3fe07a, len: 0.55, width: 0.3, head: 0.48, trail: 0x62f08a, muzzle: 0x62f08a, hit: 'heal' },
bomb: { look: 'shell', speed: 8, tint: 0xffeed0, glow: 0xff8a3d, len: 0.85, width: 0.3, head: 0.56, trail: 0xff9c4a, arc: 1.1, smoke: 0x2e2824, muzzle: 0xffb35c, hit: 'boom' },
lob: { look: 'shell', speed: 8, tint: 0xfff4dc, glow: 0xffb04a, len: 0.7, width: 0.26, head: 0.5, trail: 0xffc27a, arc: 1.4, smoke: 0x3a322c, muzzle: 0xffc27a, hit: 'splash' },
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
+160 -50
View File
@@ -600,11 +600,21 @@ export function groundTexture(img = null, crop = null) {
// =============================================================================================================
// FX atlas
const FX_SIZE = 512;
const FX_W = 1024, FX_H = 512;
/** Status icons: one 32 px row at the bottom of the FX atlas (frames `st_<key>`). */
const STATUS_ROW_Y = 448;
let _fx = null;
/**
* FX atlas frames — name: [x, y, w, h, draw(ctx, x, y, w, h)]. Every frame owns its cell: no two frames share a pixel
* (the light pillar once ran into the status-icon row, so every skill / deploy pillar carried the 'silence' and 'slow'
* icons at its foot) and each drawing stays off its cell's outer pixel (mipmapped sampling of a small sprite must not
* pull in a neighbour). Frame sizes are part of the API — sprites are scaled by them: glow / soft / ring / hex / shock /
* reticle / smoke / flare 128², spark / plus / orb / chevron / boomerang / muzzle 64², coin 48², dot / shard / square
* 32², streak / tracer / bolt 128×32 (head at the right), slash 128×64 (arc bulging up), pillar 64×256 (foot at the
* bottom).
*/
const FX_DRAW = {
// name: [x, y, w, h, draw(ctx, x, y, w, h)]
glow: [0, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]); }],
soft: [128, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,0.8)'], [1, 'rgba(255,255,255,0)']]); }],
ring: [256, 0, 128, 128, (c, x, y, w) => {
@@ -625,68 +635,154 @@ const FX_DRAW = {
c.stroke(); c.globalAlpha = 1;
for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; c.fillStyle = '#fff'; c.fillRect(cx + Math.cos(a) * 60 - 3, cy + Math.sin(a) * 60 - 3, 6, 6); }
}],
spark: [0, 128, 64, 64, (c, x, y, w) => {
// a thin sharp ring with a faint wake inside: shockwaves (skill bursts, explosions, the bombard warning)
shock: [512, 0, 128, 128, (c, x, y, w) => {
radial(c, x + w / 2, y + w / 2, w / 2 - 1, [[0, 'rgba(255,255,255,0)'], [0.52, 'rgba(255,255,255,0)'], [0.8, 'rgba(255,255,255,0.16)'],
[0.9, 'rgba(255,255,255,1)'], [0.96, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
}],
// lock-on reticle: circle, cross ticks, corner brackets, centre dot (蕾缪安 locks, marks)
reticle: [640, 0, 128, 128, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
c.strokeStyle = '#fff'; c.lineCap = 'round';
c.lineWidth = 5; c.beginPath(); c.arc(cx, cy, 36, 0, Math.PI * 2); c.stroke();
c.lineWidth = 2; c.globalAlpha = 0.55; c.beginPath(); c.arc(cx, cy, 27, 0, Math.PI * 2); c.stroke(); c.globalAlpha = 1;
c.lineWidth = 5;
for (let k = 0; k < 4; k++) {
const a = (k * Math.PI) / 2, ca = Math.cos(a), sa = Math.sin(a);
c.beginPath(); c.moveTo(cx + ca * 22, cy + sa * 22); c.lineTo(cx + ca * 50, cy + sa * 50); c.stroke();
}
c.lineWidth = 4;
for (let k = 0; k < 4; k++) {
const sx = k & 1 ? 1 : -1, sy = k & 2 ? 1 : -1, bx = cx + sx * 52, by = cy + sy * 52;
c.beginPath(); c.moveTo(bx - sx * 14, by); c.lineTo(bx, by); c.lineTo(bx, by - sy * 14); c.stroke();
}
c.fillStyle = '#fff'; c.beginPath(); c.arc(cx, cy, 4, 0, Math.PI * 2); c.fill();
}],
smoke: [768, 0, 128, 128, (c, x, y) => {
const r = rng(77);
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
}],
// star flare: hot centre, four long and four short rays (skill flash, explosion core, impacts)
flare: [896, 0, 128, 128, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2 - 1, [[0, 'rgba(255,255,255,0.95)'], [0.12, 'rgba(255,255,255,0.55)'], [0.4, 'rgba(255,255,255,0.1)'], [1, 'rgba(255,255,255,0)']]);
const ray = (a, len, half) => {
c.save(); c.translate(cx, cy); c.rotate(a);
const g = c.createLinearGradient(0, 0, len, 0);
g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.moveTo(0, -half); c.lineTo(len, 0); c.lineTo(0, half); c.closePath(); c.fill();
c.restore();
};
for (let k = 0; k < 4; k++) ray((k * Math.PI) / 2, 60, 5);
for (let k = 0; k < 4; k++) ray(Math.PI / 4 + (k * Math.PI) / 2, 30, 3);
}],
pillar: [0, 128, 64, 256, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y + 1, w, h - 2);
const v = c.createLinearGradient(0, y, 0, y + h);
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
}],
spark: [64, 128, 64, 64, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.9)'], [0.2, 'rgba(255,255,255,0.35)'], [1, 'rgba(255,255,255,0)']]);
c.fillStyle = 'rgba(255,255,255,1)';
c.beginPath(); c.moveTo(cx, y + 2); c.lineTo(cx + 3, cy - 3); c.lineTo(x + w - 2, cy); c.lineTo(cx + 3, cy + 3); c.lineTo(cx, y + w - 2);
c.lineTo(cx - 3, cy + 3); c.lineTo(x + 2, cy); c.lineTo(cx - 3, cy - 3); c.closePath(); c.fill();
}],
plus: [64, 128, 64, 64, (c, x, y, w) => {
plus: [128, 128, 64, 64, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.5)'], [1, 'rgba(255,255,255,0)']]);
c.fillStyle = '#fff';
c.fillRect(cx - 5, cy - 18, 10, 36); c.fillRect(cx - 18, cy - 5, 36, 10);
}],
orb: [128, 128, 64, 64, (c, x, y, w) => {
orb: [192, 128, 64, 64, (c, x, y, w) => {
radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.3, 'rgba(255,255,255,0.85)'], [0.55, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
}],
dot: [192, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
shard: [224, 128, 32, 32, (c, x, y, w) => {
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
}],
streak: [0, 192, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.7, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,1)');
c.fillStyle = g;
c.beginPath(); c.moveTo(x, y + h / 2 - 1); c.lineTo(x + w - 10, y + h / 2 - 5); c.lineTo(x + w, y + h / 2); c.lineTo(x + w - 10, y + h / 2 + 5); c.lineTo(x, y + h / 2 + 1); c.closePath(); c.fill();
}],
slash: [128, 192, 128, 64, (c, x, y, w, h) => {
c.save(); c.beginPath(); c.rect(x, y, w, h); c.clip();
const g = c.createLinearGradient(x, y, x + w, y);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.ellipse(x + w / 2, y + h * 1.3, w * 0.48, h * 1.05, 0, Math.PI, 0); c.ellipse(x + w / 2, y + h * 1.45, w * 0.44, h * 1.05, 0, 0, Math.PI, true); c.fill();
c.restore();
}],
pillar: [256, 128, 64, 256, (c, x, y, w, h) => {
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y, w, h);
const v = c.createLinearGradient(0, y, 0, y + h);
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
}],
smoke: [320, 128, 128, 128, (c, x, y, w) => {
const r = rng(77);
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
}],
chevron: [448, 128, 64, 64, (c, x, y, w) => {
chevron: [256, 128, 64, 64, (c, x, y) => {
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 14, y + 8); c.lineTo(x + 30, y + 8); c.lineTo(x + 52, y + 32); c.lineTo(x + 30, y + 56); c.lineTo(x + 14, y + 56); c.lineTo(x + 36, y + 32); c.closePath(); c.fill();
}],
bolt: [0, 256, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x, y, w, h);
// 回环射手 boomerang: two curved arms meeting at an elbow, centred on the cell (it spins about the centre)
boomerang: [320, 128, 64, 64, (c, x, y, w) => {
c.save(); c.translate(x + w / 2, y + w / 2 + 3);
c.beginPath();
c.moveTo(0, -21);
c.quadraticCurveTo(19, -17, 27, 14); c.quadraticCurveTo(21, 18, 14, 11); c.quadraticCurveTo(8, -5, 0, -8);
c.quadraticCurveTo(-8, -5, -14, 11); c.quadraticCurveTo(-21, 18, -27, 14); c.quadraticCurveTo(-19, -17, 0, -21);
c.closePath();
c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
c.lineWidth = 2.5; c.lineJoin = 'round'; c.strokeStyle = '#fff'; c.stroke();
c.restore();
}],
square: [128, 256, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
coin: [160, 256, 48, 48, (c, x, y, w) => {
// muzzle flash pointing right (+x) from a hot spot at (12, 32): sprite anchor (0.19, 0.5), rotated to the shot
muzzle: [384, 128, 64, 64, (c, x, y, w, h) => {
const cx = x + 12, cy = y + h / 2;
radial(c, cx, cy, 11, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
const g = c.createLinearGradient(cx, 0, x + w - 2, 0);
g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g;
c.beginPath(); c.moveTo(cx, cy - 7); c.quadraticCurveTo(cx + 26, cy - 12, x + w - 2, cy); c.quadraticCurveTo(cx + 26, cy + 12, cx, cy + 7); c.closePath(); c.fill();
c.beginPath(); c.moveTo(cx + 2, cy - 3); c.lineTo(cx + 18, cy - 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
c.beginPath(); c.moveTo(cx + 2, cy + 3); c.lineTo(cx + 18, cy + 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
}],
coin: [448, 128, 48, 48, (c, x, y, w) => {
const cx = x + w / 2, cy = y + w / 2;
c.fillStyle = '#ffc600'; c.beginPath(); c.arc(cx, cy, 20, 0, Math.PI * 2); c.fill();
c.fillStyle = '#fff2a8'; c.beginPath(); c.arc(cx - 4, cy - 4, 11, 0, Math.PI * 2); c.fill();
c.fillStyle = '#c98f00'; c.font = 'bold 22px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('$', cx, cy + 1);
}],
dot: [512, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
shard: [544, 128, 32, 32, (c, x, y, w) => {
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
}],
square: [576, 128, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
// melee swing: a thick crescent (arc bulging up), hot in the middle, tips fading, a bright rim
slash: [640, 128, 128, 64, (c, x, y, w, h) => {
const cx = x + w / 2;
const g = c.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.28, 'rgba(255,255,255,0.8)'); g.addColorStop(0.55, 'rgba(255,255,255,1)');
g.addColorStop(0.82, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.save(); c.beginPath(); c.rect(x + 1, y + 1, w - 2, h - 2); c.clip();
c.beginPath();
c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI, 0);
c.ellipse(cx, y + h * 1.42, w * 0.43, h, 0, 0, Math.PI, true);
c.closePath();
c.globalAlpha = 0.85; c.fillStyle = g; c.fill(); c.globalAlpha = 1;
c.lineWidth = 3; c.strokeStyle = g;
c.beginPath(); c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI * 1.06, Math.PI * 1.94); c.stroke();
c.restore();
}],
// soft trail (orbs, shells, dashes): tapered band with a soft cross-section, transparent tail at the left
streak: [768, 128, 128, 32, (c, x, y, w, h) => {
const cy = y + h / 2;
const v = c.createLinearGradient(0, cy - 10, 0, cy + 10);
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.5, 'rgba(255,255,255,0.95)'); v.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = v;
c.beginPath(); c.moveTo(x + 1, cy - 2); c.lineTo(x + w - 14, cy - 10); c.quadraticCurveTo(x + w - 1, cy, x + w - 14, cy + 10); c.lineTo(x + 1, cy + 2); c.closePath(); c.fill();
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 1, cy - 0.5); c.lineTo(x + w - 10, cy - 2.5); c.lineTo(x + w - 4, cy); c.lineTo(x + w - 10, cy + 2.5); c.lineTo(x + 1, cy + 0.5); c.closePath(); c.fill();
fadeTail(c, x, y, w, h, 0.6);
}],
// bullet tracer: glow band + hot core line + bright head at the right
tracer: [768, 160, 128, 32, (c, x, y, w, h) => {
const cy = y + h / 2;
const v = c.createLinearGradient(0, y + 1, 0, y + h - 1);
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.32, 'rgba(255,255,255,0.22)'); v.addColorStop(0.5, 'rgba(255,255,255,0.75)');
v.addColorStop(0.68, 'rgba(255,255,255,0.22)'); v.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = v; c.fillRect(x + 1, y + 1, w - 10, h - 2);
c.fillStyle = '#fff';
c.beginPath(); c.moveTo(x + 1, cy - 0.8); c.lineTo(x + w - 10, cy - 2.6); c.lineTo(x + w - 10, cy + 2.6); c.lineTo(x + 1, cy + 0.8); c.closePath(); c.fill();
fadeTail(c, x, y, w, h, 0.45);
radial(c, x + w - 10, cy, 9, [[0, 'rgba(255,255,255,1)'], [0.45, 'rgba(255,255,255,0.75)'], [1, 'rgba(255,255,255,0)']]);
}],
bolt: [896, 128, 128, 32, (c, x, y, w, h) => {
const g = c.createLinearGradient(0, y, 0, y + h);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(x + 1, y, w - 2, h);
}],
};
function radial(c, cx, cy, r, stops) {
@@ -696,27 +792,41 @@ function radial(c, cx, cy, r, stops) {
c.fillRect(cx - r, cy - r, r * 2, r * 2);
}
/** Fade a horizontal frame towards its tail (left edge transparent → `mid` alpha at 55 % → opaque head at the right). */
function fadeTail(c, x, y, w, h, mid) {
c.globalCompositeOperation = 'destination-in';
const g = c.createLinearGradient(x, 0, x + w - 8, 0);
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.55, `rgba(255,255,255,${mid})`); g.addColorStop(1, 'rgba(255,255,255,1)');
c.fillStyle = g; c.fillRect(x, y, w, h);
c.globalCompositeOperation = 'source-over';
}
/** The FX atlas layout (no canvas needed): `{ size: [w, h], frames: { name: [x, y, w, h] } }` incl. `st_<key>` icons. */
export function fxFrames() {
const frames = {};
for (const [name, [x, y, w, h]] of Object.entries(FX_DRAW)) frames[name] = [x, y, w, h];
STATUS_KEYS.forEach((k, i) => { frames['st_' + k] = [(i % 32) * 32, STATUS_ROW_Y + Math.floor(i / 32) * 32, 32, 32]; });
return { size: [FX_W, FX_H], frames };
}
/** FX atlas: `{ base, tex: { [name]: PIXI.Texture } }` (all frames share one base texture). */
export function fxAtlas() {
if (_fx) return _fx;
const P = PIXI();
const canvas = makeCanvas(FX_SIZE, FX_SIZE);
const canvas = makeCanvas(FX_W, FX_H);
const ctx = canvas.getContext('2d');
const frames = {};
const { frames } = fxFrames();
for (const [name, [x, y, w, h, draw]] of Object.entries(FX_DRAW)) {
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
try { draw(ctx, x, y, w, h); } catch { /* keep blank */ }
ctx.restore();
frames[name] = [x, y, w, h];
}
// status icons row (32 px each) at y = 320
STATUS_KEYS.forEach((k, i) => {
const x = (i % 16) * 32, y = 320 + Math.floor(i / 16) * 32;
for (const k of STATUS_KEYS) {
const [x, y] = frames['st_' + k];
ctx.save(); ctx.beginPath(); ctx.rect(x, y, 32, 32); ctx.clip();
try { drawStatusIcon(ctx, k, x, y, 32); } catch { /* ignore */ }
ctx.restore();
frames['st_' + k] = [x, y, 32, 32];
});
}
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
const tex = {};
for (const [name, [x, y, w, h]] of Object.entries(frames)) tex[name] = new P.Texture(base, new P.Rectangle(x, y, w, h));
+67 -12
View File
@@ -22,12 +22,18 @@
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
// every unit does.
//
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape.
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
import { BODY_H, bodyBounds, hitBody } from './pick.js';
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
@@ -137,6 +143,9 @@ export class UnitView {
this.shake = 0;
this.screen = { x: 0, y: 0, s: 1, top: 0 };
this.destroyed = false;
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
this._flip = this.visFacing; // drawn facing (±1) of the model
// --- display objects
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
@@ -397,6 +406,7 @@ export class UnitView {
this.imp = null;
}
this._box = null;
this._bodyH = null; // the next model is measured again
const old = this.actor;
this.actor = null;
this.spineReady = false;
@@ -514,6 +524,7 @@ export class UnitView {
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
if (this._cull(bx, by, s, dt)) return;
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
this._flip = flip;
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
@@ -558,6 +569,8 @@ export class UnitView {
this.actor.update(animDt);
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
}
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
}
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
if (!spineShown) this._ensurePicture();
@@ -693,6 +706,16 @@ export class UnitView {
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
}
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
if (this.chip && this.chip.visible) {
const cw = this.chip.width / 2, ch = this.chip.height / 2;
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
}
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
// status icons row above the bars
const icons = this._iconKeys();
const isz = clamp(s * 0.26, 12, 26);
@@ -858,18 +881,39 @@ export class UnitView {
return out;
}
/** Canvas-space bounds (CSS px) of the body. */
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */
_measureBody() {
let b = null;
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
}
/**
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
* until the model is there.
* @returns {import('./pick.js').PickBody}
*/
pickShape() {
const sc = this.screen;
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
}
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
bounds() {
const s = this.screen.s || 1;
const h = (this._headTiles || UNIT.headroom) * s;
const w = s * 0.7;
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
return bodyBounds({ ...this.pickShape(), hud: null });
}
hitTest(x, y) {
if (!this.alive || this.alpha < 0.05) return false;
const b = this.bounds();
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
return hitBody(this.pickShape(), x, y) > 0;
}
setHover(on) { this.hovered = !!on; }
@@ -921,7 +965,10 @@ function defaultInterval(ctx, info) {
// =============================================================================================================
/** Hand item: icon plate floating above its slot. */
/**
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn.
*/
export class ItemView {
constructor(ctx, info) {
const P = ctx.P;
@@ -946,21 +993,29 @@ export class ItemView {
}
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
update(dt, cam, t) {
const p = cam.project(this.x, this.y, this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
const lifted = this.lift > 0;
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
this.root.position.set(p.x, p.y);
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
const size = p.s * 0.62;
this.plate.scale.set(size / 128);
this.plate.anchor.set(0.5, lifted ? 0.5 : 1);
this.root.alpha = this.dimmed ? 0.35 : 1;
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
const sh = cam.project(this.x, this.y, this.z, SH_P);
this.shadow.position.set(sh.x, sh.y);
this.shadow.scale.set((p.s * 0.55) / this.shadow.texture.width);
this.shadow.alpha = 0.35;
this.screen.top = p.y - size;
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
}
bounds() { const s = this.screen.s; const w = s * 0.62; return { x: this.screen.x - w / 2, y: this.screen.y - w, width: w, height: w }; }
hitTest(x, y) { const b = this.bounds(); return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height; }
/** The plate on screen for render/pick.js: a square centred on the icon. */
pickShape() {
const s = this.screen.s || 1;
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31;
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
}
bounds() { return bodyBounds(this.pickShape()); }
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
setHover() {}
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
}
+60 -15
View File
@@ -35,13 +35,17 @@
// Solo battles (ui/matchStatus.js pauseAvailable): the top bar's pause button / Space send g.pause {on}; m.public.paused
// shows the paused overlay (继续作战 / 放弃模拟) and freezes the HUD clocks. Boss rounds: the top bar shows the level's
// 120 s countdown and the red DOT overtime warning (m.public.overtimeAt).
// User playtest #3: the own LP drops live while the own battle's enemies enter the blue gate (item 2: the runner's
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while
// the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js
// TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js';
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js';
import { TopBar } from '../ui/hud.js';
import { TopBar, liveLp, ownLeaks, tempInfo, tempReadyReason } from '../ui/hud.js';
import { BondStrip, BondPopup } from '../ui/bondStrip.js';
import { TeamPanel } from '../ui/teamPanel.js';
import { ShopBar } from '../ui/shopBar.js';
@@ -58,7 +62,7 @@ import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
import { openGuide } from '../ui/guide.js';
import { actions } from '../ui/gameActions.js';
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
import { Underframe, underframeRect } from '../ui/underframe.js';
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js';
import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js';
import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
@@ -205,6 +209,18 @@ function MatchScreen() {
if (!paused) pauseSeenRef.current = null;
else if (pauseSeenRef.current == null) pauseSeenRef.current = serverNow();
const frozenAt = paused ? frozenNow(pub, serverNow(), pauseSeenRef.current) : null;
// live LP (user playtest #3 item 2): the own normal battle's counted leaks — the local runner's count (authoritative
// or display replica) and the server's m.public players[].pendingLp, whichever is further (both only grow during a
// round; a display replica stands still while the player watches a teammate's field, the server's count then moves
// on) — cost min(lpCapPerRound, n) at settlement; shown at once until the settled m.private lands (ui/hud.js liveLp
// keeps the base between renders)
const lpBaseRef = useRef(null);
const localLeaks = battleState && battleState.leaks ? battleState.leaks[ownFieldId(myId)] : undefined;
const liveLpNow = liveLp(lpBaseRef.current, {
phase, round: pub?.round, lp: priv?.lp, statsLeaks: priv?.stats?.leaks, alive,
leaks: ownLeaks(localLeaks, meP?.pendingLp), cap: gd.config?.lpCapPerRound,
});
lpBaseRef.current = liveLpNow.base;
// latest values for event handlers bound once
const live = useRef({});
@@ -574,7 +590,9 @@ function MatchScreen() {
useEffect(() => {
if (!view) return undefined;
let moveOff = null;
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null };
// `over`: the render engine's own pick of the unit under the dragged item (pieceDragOver / pieceDrop `over`, the
// shared rule of render/pick.js at the plate) — undefined while the view does not report it (DOM fallback)
const ptr = { x: NaN, y: NaN, released: false, tile: null, unit: null, over: undefined };
const insideBar = (x, y) => {
const el = barRef.current;
if (!el || !Number.isFinite(x)) return false;
@@ -582,8 +600,11 @@ function MatchScreen() {
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
};
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
// own operator / summon whose sprite is under the pointer (not the dragged piece)
// own operator / summon under the dragged item (not the dragged piece): the engine's report, else the pieces'
// screen shapes at the pointer (ui/gameLogic.js pickPieceAt — the same rule; the tile under the pointer decides
// where no face / torso is hit)
const unitUnderPointer = (uid) => {
if (ptr.over !== undefined) return ptr.over === uid ? null : ptr.over;
const L = live.current;
if (typeof view.pieceScreenRect !== 'function' || !L.placeCtx || !Number.isFinite(ptr.x)) return null;
const rects = [];
@@ -591,9 +612,10 @@ function MatchScreen() {
if (en.piece.uid === uid || (en.area !== 'board' && en.area !== 'hand') || (en.piece.kind !== 'chess' && en.piece.kind !== 'token')) continue;
let r = null;
try { r = view.pieceScreenRect(en.piece.uid); } catch { r = null; }
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom });
if (r) rects.push({ uid: en.piece.uid, left: r.left, top: r.top, right: r.right, bottom: r.bottom, shape: r.shape || null, tile: pieceTile(en) });
}
return pickPieceAt(rects, ptr.x, ptr.y);
const at = ptr.tile && Number.isInteger(ptr.tile.row) ? { row: ptr.tile.row, col: ptr.tile.col } : null;
return pickPieceAt(rects, ptr.x, ptr.y, at);
};
const retarget = (uid, target) => {
const L = live.current;
@@ -601,6 +623,13 @@ function MatchScreen() {
return equipRetarget(L.placeCtx, uid, target, unitUnderPointer(uid));
};
retargetRef.current = retarget;
/** Light the tile of the operator a dragged item would equip (the drop target highlight follows the plate). */
const markItemTarget = (uid) => {
const t = retarget(uid, null);
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
const key = JSON.stringify(unit);
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
};
const runIntent = async (intent) => {
const L = live.current;
if (intent.confirmReplace) {
@@ -625,6 +654,7 @@ function MatchScreen() {
};
const endDrag = () => {
moveOff?.(); moveOff = null;
ptr.over = undefined; // (a drop / drag end hands its own `over` in again)
setDrag(null);
view.highlightTiles(null, null);
};
@@ -650,16 +680,12 @@ function MatchScreen() {
const { legal } = boardTargets(L.placeCtx, entry.piece.uid);
view.highlightTiles(null, null);
view.highlightTiles(legal, 'legal');
ptr.released = false; ptr.tile = null; ptr.unit = null;
ptr.released = false; ptr.tile = null; ptr.unit = null; ptr.over = undefined;
const isItem = entry.piece.kind === 'item';
// capture phase: the pointer position is current before the drag controller (canvas listener) asks canPlace
const onMove = (ev) => {
ptr.x = ev.clientX; ptr.y = ev.clientY;
if (!isItem) return;
const t = retarget(entry.piece.uid, null);
const unit = t && t.area === 'board' ? [[t.row, t.col]] : t && t.area === 'hand' ? [[GEO.HAND_ROW, t.idx]] : [];
const key = JSON.stringify(unit);
if (key !== ptr.unit) { ptr.unit = key; view.highlightTiles(unit, 'target'); }
if (isItem && ptr.over === undefined) markItemTarget(entry.piece.uid); // the engine reports via pieceDragOver
};
const onUp = (ev) => { ptr.x = ev.clientX; ptr.y = ev.clientY; ptr.released = true; };
window.addEventListener('pointermove', onMove, { passive: true, capture: true });
@@ -670,12 +696,19 @@ function MatchScreen() {
};
}),
view.on('tileHover', (t) => { ptr.tile = t && typeof t === 'object' ? t : null; }),
// the render engine's pick of the unit under a dragged item (sent before the drag asks canPlace)
view.on('pieceDragOver', (e) => {
if (!e || !live.current.placeCtx?.pieces.get(e.uid)) return;
ptr.over = Number.isInteger(e.over) ? e.over : null;
markItemTarget(e.uid);
}),
view.on('pieceDrop', async (e) => {
endDrag();
const L = live.current;
if (!e || !L.editable) return;
const entry = L.placeCtx?.pieces.get(e.uid);
if (!entry) return;
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
const t = retarget(e.uid, e.target || {});
if (t.area === 'outside') return; // no drag-to-sell (research 09 §5): the piece goes back
const res = canPlace(L.placeCtx, e.uid, t);
@@ -701,6 +734,7 @@ function MatchScreen() {
if (onOwnSlot) return; // put back where it was
// released where the drag controller found no target: equipment over an operator's sprite still equips it,
// anything else says why it was refused
if ('over' in e) ptr.over = Number.isInteger(e.over) ? e.over : null;
const t = retarget(e.uid, null);
if (t) {
const res = canPlace(L.placeCtx, e.uid, t);
@@ -917,7 +951,9 @@ function MatchScreen() {
e.preventDefault(); // a focused HUD button must not also activate (Space) — see shortcutFor
if (act === 'ready' && e.target instanceof HTMLElement && e.target.closest('button, [role="button"]')) e.target.blur();
if (act === 'ready') {
if (!L.priv.ready && shopBlockReason('ready', { priv: L.priv, editable: true })) { audio.sfx('error', { volume: 0.5 }); return; }
const refused = !L.priv.ready ? shopBlockReason('ready', { priv: L.priv, editable: true }) : null;
// the temp overflow row blocks it: say why (the button shows it too — user playtest #3 item 3)
if (refused) { audio.sfx('error', { volume: 0.5 }); toast(tempReadyReason(L.priv) || refused, 'warn'); return; }
toggleReady(!L.priv.ready);
return;
}
@@ -958,6 +994,11 @@ function MatchScreen() {
const watchingNow = combat ? (watching || field?.fieldId || home) : watching;
const shopOpen = showShop && !collapsed;
const ufShown = !!(selEntry && editable && !facing && !drag && showPrep && ufGeo);
// the temp overflow row holds pieces (user playtest #3 item 3): framed and labelled on the own prep board
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -994,18 +1035,21 @@ function MatchScreen() {
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
<div class="gm__vignette" aria-hidden="true"></div>
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null}
<div class="gm__hud" ref=${hudElRef}>
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
onExit=${() => setExitOpen(true)} onDrawer=${(t) => setDrawer((d) => (d ? null : t))} onReady=${toggleReady}
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null} />
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} />
<div class="gm__bonds">
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondOpen} owner=${watchedP ? watchedP.name : null}
@@ -1013,6 +1057,7 @@ function MatchScreen() {
</div>
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer}
self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite } : null}
observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} />
<div class="gm__effects"><${EffectsList} effects=${priv?.effects} /></div>
+2 -2
View File
@@ -13,7 +13,7 @@
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, confirmDialog, hasDeadline } from '../ui/components.js';
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data, useData, localAsset } from '../data.js';
@@ -362,7 +362,7 @@ function LoadoutScreen({ st }) {
</div>
</header>
<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>
${!ready ? html`<div class="lo-loading"><span class="spinner"></span>正在读取干员数据…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
+4 -2
View File
@@ -7,8 +7,10 @@
// room – last `room.state` payload (without `t`) or null
// match – { public, private, field, result } from the `m.*` pushes; `battle` = the local battle runner's
// state (client-side combat, public/js/battle/runner.js): { battleId, fieldId, kind, authoritative,
// watch, done, own, members, loading } | null. Under client-side combat `field` is published by the
// runner (the m.field shape of the battle on screen, `local: true`).
// watch, done, own, members, loading, speed, paused, leaks } | null — `paused`: the solo pause holds
// every local clock; `leaks`: { [fieldId]: counted leaks so far } of every normal field simulated
// locally (the live LP of ui/hud.js liveLp, user playtest #3 item 2). Under client-side combat `field`
// is published by the runner (the m.field shape of the battle on screen, `local: true`).
// ticker – recent `m.ticker` lines, emotes – recent `m.emote` events
// clock – { offset, rtt } server clock correction: serverNow ≈ Date.now() + offset
// ui – small bits of local UI state shared between screens
+17 -4
View File
@@ -1,9 +1,11 @@
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
// avatar badge. Clicking an available card on your turn sends g.choice.
// avatar badge. Clicking an available card on your turn sends g.choice; while it is in flight the card shows a
// "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon as the
// pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
import { html, Icon, TierChip, Countdown, MicroLabel, Spinner } from './components.js';
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js';
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
import { richTextPlain } from './richText.js';
@@ -47,6 +49,15 @@ export function resolveSpCard(card, family) {
};
}
/**
* Whether a card shows the in-flight pick (g.choice sent, no answer yet): only until the pick lands in m.public — the
* player's pick is known or the card is taken — so it can never outlive the request's effect.
* @param {number|null} busyIdx @param {{ idx: number, takenBy?: string|null }} card @param {number|null|undefined} mine
*/
export function pickBusy(busyIdx, card, mine) {
return busyIdx != null && !!card && busyIdx === card.idx && mine == null && !card.takenBy;
}
/**
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
@@ -94,7 +105,9 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
const r = resolveSpCard(card, sp.family);
const taker = card.takenBy ? players.get(card.takenBy) : null;
const can = myTurn && mine == null && !card.takenBy && busyIdx == null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busyIdx === card.idx && 'is-busy')}
const busy = pickBusy(busyIdx, card, mine);
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busy && 'is-busy')}
aria-busy=${busy ? 'true' : undefined}
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
@@ -108,7 +121,7 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
</span>
</span>
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
${busyIdx === card.idx ? html`<span class="spcard__spin"><${Spinner} size="sm" /></span>` : null}
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
</button>`;
})}
</div>
+5 -2
View File
@@ -111,7 +111,9 @@ export function Chevrons({ count = 3, dir = 'right', tone = 'mint', animated = t
// ---- Button ------------------------------------------------------------------------------------
/**
* Button.
* Button. `loading` (a request in flight): disabled and dimmed, and after a short delay a thin bar sweeps along its
* bottom edge (css .btn__busy) — never a spinning ring or a wait cursor, which read as "this text is still loading from
* the server" (user playtest #3 item 9); the icon stays.
* @param {{ variant?: 'primary'|'secondary'|'danger'|'amber'|'ice'|'ghost', size?: 'sm'|'md'|'lg'|'xl',
* icon?: string, iconRight?: string, loading?: boolean, disabled?: boolean, block?: boolean, square?: boolean,
* active?: boolean, onClick?: Function, type?: string, title?: string, class?: string, children?: any }} props
@@ -126,9 +128,10 @@ export function Button({
active && 'is-active', loading && 'is-loading', cls)}
disabled=${isDisabled} aria-busy=${loading ? 'true' : undefined} aria-pressed=${active ? 'true' : undefined}
onClick=${(e) => { if (!isDisabled && onClick) onClick(e); }} ...${rest}>
${loading ? html`<span class="btn__spin" aria-hidden="true"></span>` : icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
${icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
${children != null && children !== false ? html`<span class="btn__label">${children}</span>` : null}
${iconRight ? html`<${Icon} name=${iconRight} class="btn__icon btn__icon--right" />` : null}
${loading ? html`<span class="btn__busy" aria-hidden="true"></span>` : null}
</button>`;
}
+64 -31
View File
@@ -1,8 +1,11 @@
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
// reached tier, active state — tap one for its popup); then stats, range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP info, rich description,
// 已调配 when not the default), elite module (模组: official type icon from the local-client art, else its letter), talents, 特质 (garrison), equipped items; items — icon, tier,
// reached tier, active state — tap one for its popup); right under the header the operator's own effect (特质 —
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
@@ -21,7 +24,8 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_support', SERVER_PREP_FIN: 's_icon_support', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
@@ -40,7 +44,7 @@ export function RangeGrid({ grid, class: cls }) {
}
function Stat({ k, v, sub }) {
return html`<div class="dstat"><span class="dstat__k">${k}</span><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</div>`;
return html`<div class="dstat"><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
}
function Section({ title, micro, children, class: cls }) {
@@ -101,7 +105,41 @@ function traitText(c, golden, lo) {
return t.moduleDescRaw || base;
}
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
/**
* Order of an operator card's blocks (user playtest #3 item 8): the operator's own effect (特质 — garrison: its trigger
* such as 休整期结束时 and what it does) right under the header, visible without scrolling; the class trait (特性) and
* the stats next; then the skill, the elite's module, the equipped items (the player's own build) and the talents.
*/
export const CHESS_SECTIONS = Object.freeze(['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
/**
* Sprite key (ui `garrisonTypeIcon/…`, small variant) of a 特质's type chip: the garrison's own official
* `eventTypeIcon` — icon_battle 作战能力 / icon_gold 整备能力 / icon_bond 持续叠加·单次叠加 / icon_support 特异化, the
* icon that goes with its `eventTypeDesc` — never a guess from the trigger (that put the spoked 特异化 glyph, which reads
* like a loading spinner, before the text of every <休整期开始时 / 结束时> 特质: user playtest #3 item 9).
* @param {{ eventTypeIcon?: string|null, eventType?: string }|null} garrison
*/
export function garrisonTypeIconKey(garrison) {
const k = garrison && typeof garrison.eventTypeIcon === 'string' ? garrison.eventTypeIcon : '';
if (/^icon_[a-z]+$/.test(k)) return `s_${k}`;
return EVENT_ICON[garrison?.eventType] || 's_icon_bond';
}
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) {
return html`<section class="dgarrison" aria-label="特质" data-garrison=${garrison.garrisonId || ''}>
<div class="dgarrison__head">
<span class="dgarrison__k">特质</span>
<span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
</span>
</div>
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dgarrison__text" />
</section>`;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
const m = data.get('assets');
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
const c = chess;
@@ -117,8 +155,9 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
const items = Array.isArray(piece?.items) ? piece.items : [];
const sell = c.sellPrice ?? 1;
return html`
<div class="dhead">
const blocks = {};
blocks.head = html`
<div key="head" class="dhead">
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
</div>
@@ -141,9 +180,11 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
</div>
</div>
${c.trait?.desc ? html`<p class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null}
<div class="dstats-wrap">
</div>`;
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
blocks.stats = html`
<div key="stats" class="dstats-wrap">
<div class="dstats">
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
@@ -155,8 +196,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
</div>
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} /></div>
</div>
${sk ? html`<${Section} title="技能" micro="SKILL" class="dsec--skill">
</div>`;
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
<div class="dskill" data-skill=${sk.skillId || ''}>
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
<div class="dskill__meta">
@@ -171,8 +212,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
</div>
</div>
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
<//>` : null}
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
<//>` : null;
blocks.module = golden && lo?.module ? html`<${Section} key="module" title="模组" micro="MODULE" class="dsec--module">
<div class=${cx('dmodule', lo.module.none && 'is-none')} data-module=${lo.module.id}>
${!lo.module.none && lo.module.typeName ? html`<span class="dmodule__icon" data-type=${lo.module.typeName}>
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
@@ -180,25 +221,17 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
</div>
<//>` : null}
${Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} title="天赋" micro="TALENT">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null}
${garrison ? html`<${Section} title="特质" micro="GARRISON">
<div class="dgarrison">
<span class="dgarrison__type">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${EVENT_ICON[garrison.eventType] || 's_icon_bond'}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
</span>
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dtext" />
</div>
<//>` : null}
${piece?.kind === 'chess' ? html`<${Section} title="装备" micro=${`EQUIP ${items.length}/2`}>
<//>` : null;
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
<//>` : null}
${piece && editable && piece.kind !== 'item' ? html`<div class="dactions">
<//>` : null;
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
<//>` : null;
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
</div>` : null}`;
</div>` : null;
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
}
function ItemDetail({ item, piece, editable, onDestroy }) {
+14 -4
View File
@@ -162,11 +162,21 @@ export function GIcon({ name, class: cls, title }) {
${title ? html`<title>${title}</title>` : null}<path d=${d} fill-rule="evenodd" /></svg>`;
}
/** Green LP tower + number. */
export function LpTower({ value, size = 'md', class: cls, tone }) {
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, cls)} title="目标生命值">
/**
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
*/
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
const ok = Number.isFinite(value);
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0;
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || '目标生命值'}
data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num">${Number.isFinite(value) ? value : '--'}</b>
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${`结算时扣除 ${p}`}>−${p}</span>` : null}
${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`;
}
+22 -7
View File
@@ -18,6 +18,7 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
import { layoutPen } from '../render/pen.js';
import { pickBody } from '../render/pick.js';
import { bossLevelSeconds } from './matchStatus.js';
// ---- small helpers -------------------------------------------------------------------------------
@@ -871,17 +872,31 @@ export function dropIntent(ctx, uid, target) {
}
/**
* The front-most piece whose screen rect contains (x, y): the one standing nearest the camera (largest bottom).
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number}|null>} rects
* The piece under a screen point (user playtest #3 item 7). Rects of the render engine carry the drawn body
* (`shape`, view.pieceScreenRect) and are picked by the shared rule of render/pick.js — the face / torso of a unit is
* its own; elsewhere the piece standing on the tile under the pointer (`tile`, same space as the rects' `tile`) wins.
* Plain rects (the DOM fallback) go to the one whose centre is nearest relative to its size (a unit standing in front
* no longer takes the whole overlap), ties → the front-most (largest bottom).
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number, shape?:object, tile?:{row:number,col:number}}|null>} rects
* @param {number} x
* @param {number} y
* @param {{row:number, col:number}|null} [tile] the tile under the point
* @returns {number|null} uid
*/
export function pickPieceAt(rects, x, y) {
export function pickPieceAt(rects, x, y, tile = null) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
let best = null;
for (const r of Array.isArray(rects) ? rects : []) {
if (!isObj(r) || !Number.isInteger(r.uid)) continue;
const list = (Array.isArray(rects) ? rects : []).filter((r) => isObj(r) && Number.isInteger(r.uid));
const shaped = list.filter((r) => isObj(r.shape) && r.shape.s > 0);
if (shaped.length && shaped.length === list.length) {
const hit = pickBody(shaped.map((r) => ({ ...r.shape, tile: r.tile || null, uid: r.uid })), x, y, tile);
return hit ? hit.uid : null;
}
let best = null, bestD = Infinity;
for (const r of list) {
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
if (!best || r.bottom > best.bottom) best = r;
const hw = Math.max(1, (r.right - r.left) / 2), hh = Math.max(1, (r.bottom - r.top) / 2);
const d = Math.hypot((x - (r.left + r.right) / 2) / hw, (y - (r.top + r.bottom) / 2) / hh);
if (d < bestD - 1e-9 || (Math.abs(d - bestD) <= 1e-9 && r.bottom > best.bottom)) { best = r; bestD = d; }
}
return best ? best.uid : null;
}
+107 -7
View File
@@ -12,6 +12,11 @@
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
// the leaks come from the local battle runner, else m.public players[].pendingLp).
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
import { useRef } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
@@ -56,21 +61,110 @@ export function PhaseCapsule({ pub, hud }) {
</div>`;
}
// ---- live LP of the own battle (user playtest #3 item 2) --------------------------------------------------------
/** Phases whose leaks are still to be charged at settlement (normal rounds: the own battle, then 联防). */
const LEAK_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE]);
/**
* Ready toggle (PREP only). Disabled with a reason while the temp hand is non-empty.
* LP a round's leaks cost at settlement — the server rule (server/match/Match.js settle): min(lpCapPerRound, counted
* leaks), never negative.
* @param {number} leaks counted leaks so far
* @param {number} [cap] data/config.json lpCapPerRound
*/
export function pendingLoss(leaks, cap = 10) {
const n = Math.max(0, Math.trunc(Number(leaks) || 0));
const c = Number(cap) > 0 ? Math.trunc(Number(cap)) : 10;
return Math.min(c, n);
}
/**
* Counted leaks of the own battle so far from its two sources: the local runner's count (state().leaks of the own
* field — authoritative or a display replica) and the server's m.public players[].pendingLp (the authority's b.progress,
* ~1 Hz; the recorded result in 联防). Both only grow during a round, so the further one wins: a display replica stands
* still while the player watches a teammate's field (the server's count moves on), the server's lags the local one by
* up to a second. Missing / invalid values count as 0.
* @param {any} local @param {any} server
*/
export function ownLeaks(local, server) {
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
return Math.max(n(local), n(server));
}
/**
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them) — never more than
* its own battle's count, which stays on show marked `unite` (联防中: teammates may still save part of it) until the
* settlement lands. Boss rounds are not handled here (the merged team LP moves live through b.pool / m.public.teamLp).
* @param {{ round: any, lp: number, statsLeaks: number|null } | null} base
* @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean }} s
* @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean }}
*/
export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true }) {
if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false };
if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false };
const sl = Number.isFinite(statsLeaks) ? statsLeaks : null;
const b = base && base.round === round ? base : { round, lp, statsLeaks: sl };
const landed = lp !== b.lp || (sl != null && b.statsLeaks != null && sl !== b.statsLeaks);
const pending = landed ? 0 : Math.min(lp, pendingLoss(leaks, cap));
return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && pending > 0 };
}
/**
* Tooltip of an LP tower with a pending loss (null without one).
* @param {number} lp settled LP @param {number} pending @param {{ unite?: boolean, cap?: number }} [opts]
*/
export function pendingTip(lp, pending, { unite = false, cap = 10 } = {}) {
if (!(pending > 0)) return null;
return unite
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)`
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`;
}
// ---- temp overflow row (临时整备区, user playtest #3 item 3) ---------------------------------------------------
/**
* Pieces waiting in the temp overflow row (m.private temp): how many, and how many are items.
* @param {any} priv m.private
* @returns {{ count: number, items: number }}
*/
export function tempInfo(priv) {
const list = (Array.isArray(priv?.temp) ? priv.temp : []).filter((p) => p && typeof p === 'object');
return { count: list.length, items: list.filter((p) => p.kind === 'item').length };
}
/** What the temp row asks of the player (the ready button's reason, the row's label). */
export const TEMP_RULE = '放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁';
/** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */
export function tempReadyReason(priv) {
const t = tempInfo(priv);
return t.count ? `临时整备区还有 ${t.count} 个单位:${TEMP_RULE}` : null;
}
/**
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
*/
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
const ready = !!priv?.ready;
const reason = !ready ? shopBlockReason('ready', { priv, editable: true }) : null;
const temp = tempInfo(priv);
const reason = !ready ? tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true }) : null;
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
aria-pressed=${ready ? 'true' : 'false'} onClick=${() => onToggle(!ready)}>
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
<span class="readybtn__label">${ready ? '取消准备' : '准备就绪'}</span>
<kbd class="readybtn__key">Space</kbd>
</button>`;
return html`<div class="readywrap">
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
<${Icon} name="warn" /><span>临时整备区 <b class="num">${temp.count}</b> 个单位待处理</span></span>` : null}
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
</div>`;
}
@@ -150,14 +244,18 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* @param {{ pub:any, priv:any, conn:any, hud:any, total:number|null, drawer:string|null, onExit:Function, onDrawer:(tab:string)=>void,
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null }} props
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
* live?: { pending: number, unite: boolean } | null }} props
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null }) {
config = null, frozenAt = null, pause = null, live = null }) {
const phase = pub?.phase;
const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
// normal rounds: the leaks of the own battle so far (boss rounds: the team LP above already moves live)
const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0;
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
const showReady = phase === PHASE.PREP && priv?.alive !== false;
@@ -167,7 +265,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
const now = Number.isFinite(frozenAt) ? frozenAt : serverNow();
const ot = otLive ? overtimeState(pub, now, { perSec: overtimeDrainPerSec(config) }) : null;
const draining = ot?.state === 'drain';
const lowLp = (Number.isFinite(lp) && lp <= 5) || draining;
const lowLp = (Number.isFinite(lp) && lp - pending <= 5) || draining;
const cap = Number(config?.lpCapPerRound) > 0 ? Number(config.lpCapPerRound) : 10;
const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null;
const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer });
const onLeft = () => (pen ? onPen(false) : onDrawer('info'));
@@ -191,7 +290,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
<b class="roundbox__num num">${roundText}</b>
</div>
<${PhaseCapsule} pub=${pub} hud=${hud} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending}
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap })} />
<${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
+26 -3
View File
@@ -5,8 +5,12 @@
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
// watches that player's field at once.
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
@@ -18,12 +22,29 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Official status glyphs (ui/battle) per m.public players[].status; CSS glyph fallback. */
const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'icon_complete', dead: 'icon_dead' };
/**
* A row's LP tower: the settled LP and the pending loss of the round's battle (0 outside COMBAT / UNITE). The own row
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar).
* @param {any} p m.public players[] entry @param {any} pub m.public
* @param {{ lp?: number|null, pending: number, unite: boolean } | null} [self] the own live value (only for the own row)
* @returns {{ lp: number|null, pending: number, unite: boolean }}
*/
export function rowLp(p, pub, self = null) {
const pubLp = Number.isFinite(p?.lp) ? p.lp : null;
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false };
const lp = self && Number.isFinite(self.lp) ? self.lp : pubLp;
if (lp == null) return { lp, pending: 0, unite: false };
const raw = self ? self.pending : p.pendingLp;
const pending = Math.min(lp, Math.max(0, Math.trunc(Number(raw) || 0)));
return { lp, pending, unite: pending > 0 && (self ? !!self.unite : pub.phase === PHASE.UNITE) };
}
/**
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
* compact?: boolean, teamLp?: number|null,
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean } | null,
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null }) {
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null }) {
const [openPid, setOpenPid] = useState(null);
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
@@ -47,6 +68,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
const lp = rowLp(p, pub, self ? selfLive : null);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
@@ -57,7 +79,8 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
<div class="team__info">
<span class="team__name">${p.name || '博士'}</span>
<div class="team__line">
<${LpTower} value=${p.lp} size="sm" tone=${Number.isFinite(p.lp) && p.lp <= 5 ? 'danger' : null} />
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
tip=${lp.pending > 0 ? `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点` : null} />
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
+73 -1
View File
@@ -17,11 +17,18 @@
// octagon plate: the sprite is used as a CSS mask over the plate colour (出售 amber like every money action, 销毁 /
// 撤退 red) on a dark backing that shows through the glyph cut-out. Without the local sprites the same plate carries
// the built-in glyph.
//
// TempRowNotice (user playtest #3 item 3) — the other board-anchored overlay of the prep: while the temp overflow row
// (临时整备区, row 8, the 5 pads in front of the bench) holds pieces, a dashed red frame around that row and a label at
// its end say that they block 准备就绪 and are destroyed when the prep ends unless moved to the bench, equipped or used.
// Placed through view.tileScreen (useTileScreen, so it follows the camera — the Final Assault prep too); it never takes
// the pointer, so the pieces under it stay draggable.
import { html, HexBadge } from './components.js';
import { html, HexBadge, Icon } from './components.js';
import { GIcon } from './gameComponents.js';
import { useTileScreen } from './facingWheel.js';
import { localAsset } from '../data.js';
import { GEO } from '../../../shared/constants.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -100,3 +107,68 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
</button>` : null}
</div>`;
}
// ---- the temp overflow row (临时整备区) ---------------------------------------------------------------------------
/**
* Screen frame of the temp row from its two end tiles (view.tileScreen of cols TEMP_C0 and TEMP_C0 + TEMP_SIZE − 1): the
* quad [back-left, back-right, front-right, front-left] (back = the far edge, higher on screen — also when the Final
* Assault prep mirrors the board), its bounds, and the side of the label: left of the row, or right of it when the
* left has no room for `labelW` px. Pure (test/ui/playtest3.test.js).
* @param {{ poly: Array<[number, number]> } | null} a @param {{ poly: Array<[number, number]> } | null} b
* @param {{ labelW?: number, gap?: number, vw?: number }} [opts]
* @returns {{ quad: Array<[number, number]>, left: number, right: number, top: number, bottom: number, y: number, side: 'left'|'right' } | null}
*/
export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {}) {
const ok = (t) => t && Array.isArray(t.poly) && t.poly.length >= 4 && t.poly.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (!ok(a) || !ok(b)) return null;
const backs = [a.poly[0], a.poly[1], b.poly[0], b.poly[1]];
const fronts = [a.poly[2], a.poly[3], b.poly[2], b.poly[3]];
const minX = (pts) => pts.reduce((m, p) => (p[0] < m[0] ? p : m));
const maxX = (pts) => pts.reduce((m, p) => (p[0] > m[0] ? p : m));
const quad = [minX(backs), maxX(backs), maxX(fronts), minX(fronts)];
const xs = quad.map((p) => p[0]);
const ys = quad.map((p) => p[1]);
const left = Math.min(...xs), right = Math.max(...xs), top = Math.min(...ys), bottom = Math.max(...ys);
const side = left - gap - labelW >= 0 || right + gap + labelW > vw ? 'left' : 'right';
return { quad, left, right, top, bottom, y: (top + bottom) / 2, side };
}
/**
* What the temp row's label says will happen to its pieces (server/match/PlayerState.js tempDue): a piece is resolved
* at the end of the first prep in which the player can act on it. Not ready (or outside PREP): at the end of this / the
* coming prep, and 准备就绪 waits for the row to be cleared. Ready in PREP: Ready is refused while the row holds pieces,
* so whatever lies there arrived after it — kept through the NEXT prep (cancelling Ready makes it due at this one).
* @param {boolean} ready m.private ready (during PREP)
*/
export function tempRowRule(ready) {
return ready
? '已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)'
: '放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁';
}
/**
* The temp row's notice: dashed red frame + label (count, what to do, what happens at the end of the prep — tempRowRule).
* `label` false (a piece is being dragged / placed): the frame only, nothing over the pieces the player is moving.
* @param {{ view: any, count: number, items?: number, label?: boolean, ready?: boolean }} props
*/
export function TempRowNotice({ view, count, items = 0, label = true, ready = false }) {
const a = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0);
const b = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0 + GEO.TEMP_SIZE - 1);
let rem = 100;
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* default */ }
const vw = globalThis.innerWidth || 1920;
const f = count > 0 ? tempRowFrame(a, b, { labelW: rem * 2.9, gap: rem * 0.1, vw }) : null;
if (!f) return null;
const pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
const pad = rem * 0.1;
const style = f.side === 'left' ? `left:${(f.left - pad).toFixed(1)}px;top:${f.y.toFixed(1)}px` : `left:${(f.right + pad).toFixed(1)}px;top:${f.y.toFixed(1)}px`;
const what = items > 0 && items === count ? '件道具' : '个单位';
return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status">
<b class="tempnote__title"><${Icon} name="warn" />临时整备区 <span class="num">${count}</span> ${what}待处理</b>
<span class="tempnote__rule">${tempRowRule(ready)}</span>
</div>` : null}
</div>`;
}
+29 -1
View File
@@ -72,6 +72,9 @@
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
// Solo pause (g.pause { on }, setPause): freezes the field clocks, result deadlines, server release timers, the boss
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
@@ -125,7 +128,7 @@ import {
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
} from './fields.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify } from '../sim/spec.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
import { CreditPool } from './finalAssault.js';
import { buildResult } from './results.js';
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
@@ -753,6 +756,8 @@ export class Match {
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
// the LP this round's own battle will cost at settlement so far (COMBAT / 联防 only, omitted when 0)
...this._pendingLpView(ps),
})),
fields: this.fields.map((f) => {
const v = { fieldId: f.fieldId, kind: f.kind, players: f.players.slice(), live: !!f.live };
@@ -1778,6 +1783,29 @@ export class Match {
return { killed: f.progress.killed, total: f.progress.total, done: false };
}
/**
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: the own
* battle's count — the most the 联防 can charge (settle: min(cap, survivors) ≤ min(cap, counted)). Omitted when 0 and
* in every other phase (boss rounds charge the merged team LP live).
* @returns {{ pendingLp?: number }}
*/
_pendingLpView(ps) {
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
let n = 0;
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
else if (this.phase === PHASE.COMBAT) {
const f = this.fields.find((x) => x && x.kind === 'normal' && Array.isArray(x.players) && x.players.includes(ps.playerId));
if (f && f.cc) n = f.done && f.result ? counted(f.result.perPlayer && f.result.perPlayer[ps.playerId]) : Number(f.progress && f.progress.leaks) || 0;
else if (f && f.battle) { try { n = battleProgress(f.battle).leaks; } catch { n = 0; } }
}
const loss = Math.min(this.gd.lpCapPerRound, Math.max(0, Math.trunc(Number(n) || 0)));
return loss > 0 ? { pendingLp: loss } : {};
}
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
_authorityFor(f, exclude = null) {
let best = null;
+68 -17
View File
@@ -3,8 +3,14 @@
// Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they
// return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2):
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge results, grants, returned equipment) overflow
// into temp; temp blocks Ready; temp is wiped at the next round start (and auto-resolved at the prep deadline).
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
@@ -14,8 +20,10 @@
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). Outside PREP a full hand sends the
// elite to a freed board tile of a consumed copy rather than to temp (wiped at the next round start).
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
// freed board tile of a consumed copy.
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
// absent; equipped items are otherwise locked: g.destroy refuses them),
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
@@ -81,6 +89,10 @@ export class PlayerState {
this.hand = new Array(HAND_SIZE).fill(null);
/** @type {Array<any>} */
this.temp = new Array(TEMP_SIZE).fill(null);
/** preps of this player that ended so far (endPrep) = index of the current (or next) prep */
this.prepsEnded = 0;
/** @type {Map<number, number>} temp piece uid → index of the prep whose deadline resolves it (see tempDue) */
this._tempDue = new Map();
/** @type {Map<string, any>} 'r,c' → piece */
this.board = new Map();
/** persistent bond layers */
@@ -128,6 +140,30 @@ export class PlayerState {
get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; }
get tempEmpty() { return this.temp.every((x) => x == null); }
/**
* Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered
* temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep.
*/
tempDue(piece) {
const due = piece ? this._tempDue.get(piece.uid) : undefined;
return Number.isInteger(due) ? due : this.prepsEnded;
}
/**
* Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready);
* after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变)
* the next prep to end — `prepsEnded` then already names it.
*/
_tempDueNow() {
return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0);
}
/** Every write of a piece into a temp slot goes through here (records its due prep). */
_putTemp(i, piece) {
this.temp[i] = piece;
this._tempDue.set(piece.uid, this._tempDueNow());
}
/**
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
@@ -259,7 +295,7 @@ export class PlayerState {
_detach(loc) {
if (!loc) return;
if (loc.area === 'hand') this.hand[loc.idx] = null;
else if (loc.area === 'temp') this.temp[loc.idx] = null;
else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); }
else if (loc.area === 'board') this.board.delete(loc.key);
else if (loc.area === 'equipped') {
const i = loc.holder.items.indexOf(loc.piece);
@@ -268,7 +304,8 @@ export class PlayerState {
}
/**
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots. Returns 'hand' | 'temp' | null.
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null.
*/
stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) {
if (!toTemp) {
@@ -281,7 +318,7 @@ export class PlayerState {
if (!allowTemp) return null;
}
const j = freeSlot(this.temp);
if (j >= 0) { this.temp[j] = piece; return 'temp'; }
if (j >= 0) { this._putTemp(j, piece); return 'temp'; }
return null;
}
@@ -411,10 +448,9 @@ export class PlayerState {
this.grantTokensFor(elite);
return 'board';
};
// outside PREP (a merge completed by SETTLE / battle-result effects) the player cannot clear the temp slots before
// they are wiped at the next round start: with a full hand the elite takes a freed board tile instead
const canAct = this.m.phase === PHASE.PREP;
let where = this.stow(elite, { allowTemp: canAct || !boardLoc });
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
let where = this.stow(elite, { allowTemp: true });
if (!where && boardLoc) {
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
// instead of being lost
@@ -896,7 +932,7 @@ export class PlayerState {
/** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */
_putBack(loc, piece) {
if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; }
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this.temp[loc.idx] = piece; return true; }
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; }
return !!this.stow(piece, { allowTemp: true });
}
@@ -1006,7 +1042,7 @@ export class PlayerState {
this.hand[idx] = piece;
if (occ) {
if (loc.area === 'hand') this.hand[loc.idx] = occ;
else this.temp[loc.idx] = occ;
else this._putTemp(loc.idx, occ);
}
this.recompute();
return OK;
@@ -1161,18 +1197,25 @@ export class PlayerState {
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
if (this.ready === !!on) return OK;
this.ready = !!on;
// un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline
if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded);
this.dirty();
this.m.onReadyChanged(this);
return OK;
}
/** Prep deadline / round start: temp contents are sold (copies back to the pool) or destroyed. */
/**
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
*/
resolveTemp() {
let changed = false;
for (let i = 0; i < this.temp.length; i++) {
const p = this.temp[i];
if (!p) continue;
if (!p || this.tempDue(p) > this.prepsEnded) continue;
this.temp[i] = null;
this._tempDue.delete(p.uid);
changed = true;
if (p.kind === 'chess') {
this.removeTokensOf(p.uid);
@@ -1194,8 +1237,9 @@ export class PlayerState {
this.m.dispatch(this, 'onIncome', ev);
const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' });
this.resolveTemp();
// reward offers of the last prep already expired at its end (endPrep); what is still queued was earned after it —
// temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed
// after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue).
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
this.ready = false;
this.rollShop({ keepFrozen: true });
@@ -1204,8 +1248,14 @@ export class PlayerState {
this.recompute();
}
/**
* Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are
* resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names.
*/
endPrep() {
this.resolveTemp();
this.prepsEnded++;
for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid);
this.offers = [];
this.clearUnfrozenShop();
if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0;
@@ -1225,6 +1275,7 @@ export class PlayerState {
this.board.clear();
this.hand.fill(null);
this.temp.fill(null);
this._tempDue.clear();
this.offers = [];
this.bounties = [];
this.shop.slots = [];
+48 -15
View File
@@ -2,15 +2,22 @@
//
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
// prep end leftover funds lost (carry bands excepted), temp resolved, reward offers expired
// temp (临时整备区) every piece entering a temp slot (PlayerState._putTemp) is due at the deadline of the first
// prep in which its player could act on it: the current prep when it arrived in PREP before Ready,
// the next one when it arrived after Ready / at the prep end, else (COMBAT, SETTLE, ROUND_START, 机变)
// the next prep to end; a piece that got into temp by other means counts as due at the prep in
// progress when it is first seen at a prep end. No temp piece may outlive its due prep.
// prep end leftover funds lost (carry bands excepted), temp resolved (only pieces due at a later prep stay),
// reward offers expired
// round start income = config income(r) (= min(3 + r, 12)) + pending funds, upgrade price −1 (floor 0) from R2,
// temp wiped, offers earned after the last prep kept, frozen slots kept in place (same id; chess slots
// keep their index, the item slot(s) follow the chess slots, so a level-up moves them right),
// everything else rerolled with tier ≤ shop level from the unbanned pool, freeze toggle released
// temp not wiped (nothing overdue), offers earned after the last prep kept, frozen slots kept in
// place (same id; chess slots keep their index, the item slot(s) follow the chess slots, so a
// level-up moves them right), everything else rerolled with tier ≤ shop level from the unbanned pool,
// freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base
// combat start temp empty, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared, one field per
// alive player
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
// maps consistent, 6 (co-op) / 3 (solo) cards
// 联防 decided after the COMBAT_END pause from the players still in: runs iff co-op with ≥ 1 leaker and
@@ -74,7 +81,34 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (hook === 'onIncome' && ev && ps) incomeEv.set(ps, { initial: { income: ev.income, pending: ev.pending }, ev });
return orig(ps, hook, ev, opts);
});
// temp arrivals: ps → Map(uid → index of the prep whose deadline resolves it); ps → preps ended (own count)
const tempDue = new Map();
const prepsEnded = new Map();
const endedOf = (ps) => prepsEnded.get(ps) || 0;
const dueOf = (ps) => { let d = tempDue.get(ps); if (!d) tempDue.set(ps, (d = new Map())); return d; };
const overdue = (ps, label) => {
const d = dueOf(ps);
for (const p of ps.temp) {
if (!p) continue;
const due = d.get(p.uid);
if (due != null && due < endedOf(ps)) fail(`${ps.playerId}: temp piece ${p.id} (due at prep ${due}) survived ${label} (${endedOf(ps)} preps ended)`);
}
};
for (const ps of m.players.values()) {
wrap(ps, '_putTemp', function (orig, i, piece) {
const res = orig(i, piece);
// after Ready / at the prep end the player cannot act on it any more: due at the next prep
if (piece) dueOf(ps).set(piece.uid, endedOf(ps) + (m.phase === PHASE.PREP && ps.ready ? 1 : 0));
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const was = ps.ready;
const res = orig(on);
// un-ready: the player can act on what arrived while it was ready — due at this prep again
if (res && res.ok && was && !on) for (const p of ps.temp) if (p && (dueOf(ps).get(p.uid) ?? 0) > endedOf(ps)) dueOf(ps).set(p.uid, endedOf(ps));
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
wrap(ps, '_rollChessSlot', function (orig) {
const s = orig();
if (s) {
@@ -114,7 +148,8 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const upWant = r > 1 ? Math.max(0, up0 - 1) : up0;
if (ps.shop.upgradePrice !== upWant) fail(`${id}: upgrade price ${up0} → ${ps.shop.upgradePrice}, expected ${upWant}`);
if (ps.pendingFunds !== 0) fail(`${id}: pending funds not cleared`);
if (!ps.tempEmpty) fail(`${id}: temp not wiped at round start`);
// temp is not wiped here: what overflowed after the last prep's deadline is shown in this prep
overdue(ps, 'the round start');
// offers of the last prep expired at its end; the ones queued after it (SETTLE merges) wait for this prep
if (offers0.some((o) => !ps.offers.includes(o))) fail(`${id}: a reward offer earned after the prep was dropped at the round start`);
if (ps.shop.frozen) fail(`${id}: freeze toggle still on after the round start`);
@@ -183,20 +218,18 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
return res;
});
wrap(ps, 'endPrep', function (orig) {
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
const res = orig();
prepsEnded.set(ps, endedOf(ps) + 1);
check('prep end', () => {
// leftover funds are lost at prep end (carry bands excepted); gains after this (SETTLE effects) are kept
if (ps.funds !== 0 && !carries(ps)) fail(`${ps.playerId}: kept ${ps.funds} funds past the prep end without a carry band`);
if (!ps.tempEmpty) fail(`${ps.playerId}: temp not resolved at prep end`);
overdue(ps, 'its prep end');
if (ps.offers.length) fail(`${ps.playerId}: reward offer survived the prep end`);
});
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const res = orig(on);
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
}
// ---- phases ------------------------------------------------------------------------------------------------
@@ -283,7 +316,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
check('combat start', () => {
for (const ps of m.alivePlayers()) {
const id = ps.playerId;
if (!ps.tempEmpty) fail(`${id}: temp not empty at combat start`);
overdue(ps, 'into combat');
if (!ps.ready) fail(`${id}: not ready at combat start`);
if (ps.funds !== 0 && !carries(ps)) fail(`${id}: kept ${ps.funds} funds into combat`);
if (ps.offers.length) fail(`${id}: reward offer survived the prep`);
+27 -2
View File
@@ -13,7 +13,7 @@
// ATTACK_PAUSE seconds after each attack. Reaching the final leg's end = leak. An `attract` (诱导) status suspends the
// route: the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, COLS } from './constants.js';
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
import { reduceElement } from './damage.js';
import { OB_CRATE } from './grid.js';
@@ -140,7 +140,9 @@ export function performAttack(b, u, prof, targets, opts = null) {
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId };
if (ranged && t.side === 'enemy') {
if (ranged && t.side === 'enemy' && prof.projectile === 'boomerang') {
throwBoomerang(b, u, prof, t, info);
} else if (ranged && t.side === 'enemy') {
const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED;
// projectiles land even if the shooter died meanwhile (damage is credited to it)
b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0,
@@ -154,6 +156,29 @@ export function performAttack(b, u, prof, targets, opts = null) {
if (prof.afterAttack) b._safe(() => prof.afterAttack(b, u, targets), 'profile.afterAttack', u);
}
/**
* 回环射手 boomerang (projectile 'boomerang', professions.js loopshooter): it flies out to the target at
* PROJECTILE_SPEEDS.boomerang and the attack hits on arrival; a target that died / vanished meanwhile is not hit (the
* boomerang still flies to its last position — a splash profile would burst there), then it flies back to the thrower's
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
*/
function throwBoomerang(b, u, prof, t, info) {
const seq = u.deploySeq;
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
const home = () => u.alive && u.deployed && u.deploySeq === seq;
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
onHit: (c) => {
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
if (!home()) return;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
} });
}
/** Apply one attack hit (called on impact for projectiles). `target` may be null (splash on a dead target's spot). */
export function resolveHit(b, u, prof, target, info, x, y) {
const atk = u.s.atk;
+8 -2
View File
@@ -17,8 +17,14 @@ export const MOVE_SCALE = 0.5;
export const ATTACK_PAUSE = 0.35;
/** Default projectile speed in tiles/s for ranged operators/enemies. */
export const PROJECTILE_SPEED = 12;
/** Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. */
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10 });
/**
* Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. `boomerang` (回环射手 跃跃) is the OUTBOUND
* flight to the target — PRTS 跃跃 特性 note "投射物飞行速度15,返回时飞行速度3.75"; the way back is
* BOOMERANG_RETURN_SPEED (ai.js throwBoomerang).
*/
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10, boomerang: 15 });
/** 回环射手: speed (tiles/s) of a boomerang flying back from its hit point to its thrower (PRTS "返回时飞行速度3.75"). */
export const BOOMERANG_RETURN_SPEED = 3.75;
/** Minimum damage ratio after mitigation (5 % of the pre-mitigation amount). */
export const MIN_DAMAGE_RATIO = 0.05;
+4 -1
View File
@@ -593,7 +593,10 @@ export default {
}),
// ---------------------------------------------------------------------------------------------------------------
// 1_09 跃跃 乐趣加倍: ATK +atk, each attack throws `cnt` boomerangs (cnt hits). 戏耍随心: prob per hit for
// 1_09 跃跃 (回环射手: every attack is a boomerang out to the target and back — she attacks again only once it is
// caught, professions.js loopshooter / ai.js throwBoomerang). 乐趣加倍: ATK +atk, each attack throws `cnt`
// boomerangs at the target (cnt hits); they fly the same path, so the flight carries cnt hits and all of them are
// back together ("必须回收全部回旋投掷物才可以进行下一次攻击"). 戏耍随心: prob per hit (each boomerang on its own) for
// ATK ×atk_scale. Elite module (LPS-X, trait atk_scale): vs enemies on the 8 surrounding tiles ATK ×atk_scale.
// Alternate S1 强力击·β型 (AUTO, attack SP): the next attack at atk_scale × ATK.
chess_char_1_09_a: (bb, chess) => {
+50 -11
View File
@@ -12,6 +12,9 @@
// Simplifications (one line per id; see also the report of the content phase):
// 1_15 盟约·辅助干员 element pick = neural → burn → apoptosis, skipping the elements already bursting on the target.
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
@@ -98,6 +101,7 @@
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { aggregateMods } from '../../buffs.js';
import { COLS, ROWS } from '../../constants.js';
import { rotateOffset } from '../../dir.js';
import { summonToken, TOKEN_IDS } from '../tokens.js';
// ------------------------------------------------------------------------------------------------------------------
@@ -345,6 +349,15 @@ function pithst(bb, chess, def) {
// ------------------------------------------------------------------------------------------------------------------
// 蕾缪安 chess_char_6_01 (神射手) — S3 礼炮·强制追思; 跨境追缉许可; 逃犯引渡手续
/** S3 bombardment: one shell every 0.3 s after the skill ends, for at most 10 s (PRTS S3 note, not in the data). */
const LEMUEN_SHELL_INTERVAL = 0.3;
const LEMUEN_SHELL_WINDOW = 10;
/**
* Shell flight time [ASSUMED]: the first bombardment lands one interval after the skill ends, so every shell of the
* PRTS count (≤ 33 in the 10 s window) lands inside it; the renderer draws the shell over this time (fx bombardShell).
*/
const LEMUEN_SHELL_FLIGHT = 0.3;
function ensureWanted(battle) {
const S = bstate(battle);
if (S.wanted) return S.wanted;
@@ -394,18 +407,43 @@ function lemuen(bb, chess, def) {
for (const e of cands) if (!best || cnt(e) < cnt(best)) best = e;
return best;
};
const bombard = (battle, unit, locks) => {
for (const L of locks) {
const x = L.e.alive && !L.e.hidden ? L.e.x : L.x;
const y = L.e.alive && !L.e.hidden ? L.e.y : L.y;
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
for (const e of battle.enemiesInRadius(x, y, d2)) {
if (e.s.flags.untargetable) continue;
const d = Math.hypot(e.x - x, e.y - y);
battle.dealDamage(unit, e, { amount: unit.s.atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
// S3 bombardment (PRTS 蕾缪安 S3 note — the timing is not in the blackboard): after the skill ends ONE shell every
// LEMUEN_SHELL_INTERVAL s, in lock order, on a random point of the square of side 2 × emit_offset (PRTS "边长0.4",
// emit_offset 0.2) around its lock mark — the locked enemy while it is on the field ("持续追踪锁定目标"), else the spot
// it left ("锁定标记会留在原地") — landing LEMUEN_SHELL_FLIGHT s later; each shell hits every enemy within dist_2 once
// (limited_hit_time 1): proj_atk_scale_1 × ATK within dist_1 of its point, proj_atk_scale_2 × ATK beyond, all with the
// ATK cached when the skill ended ("缓存攻击力"); at most LEMUEN_SHELL_WINDOW s of shells ("最多产生33次轰炸").
const spread = Math.max(0, num(bb['attack@emit_offset'], 0.2));
const markOf = (L) => {
const e = L.e;
if (!L.gone && (!e.alive || !e.hidden)) { L.x = e.x; L.y = e.y; if (!e.alive) L.gone = true; }
return L;
};
const blast = (battle, unit, atk, x, y) => {
battle.fx('bombard', { x, y, id: unit.id, r: d2 });
for (const e of battle.enemiesInRadius(x, y, d2)) {
if (e.s.flags.untargetable) continue;
const d = Math.hypot(e.x - x, e.y - y);
battle.dealDamage(unit, e, { amount: atk * (d <= d1 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] });
}
};
const bombard = (battle, unit, locks) => {
const atk = unit.s.atk;
const seq = unit.deploySeq;
const n = Math.min(locks.length, Math.floor(LEMUEN_SHELL_WINDOW / LEMUEN_SHELL_INTERVAL + 1e-9));
const fire = (i) => {
// knocked out / withdrawn after the skill ended: the shells not fired yet are dropped (those in the air land)
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
const L = markOf(locks[i]);
// the random offset is drawn in her facing frame (the square turns onto itself): a turned board plays alike
const [oy, ox] = rotateOffset(battle.rng.range(-spread, spread), battle.rng.range(-spread, spread), unit.dir);
const x = L.x + ox, y = L.y + oy;
battle.fx('bombardShell', { x, y, id: unit.id, r: d2, t: LEMUEN_SHELL_FLIGHT, i });
battle.after(LEMUEN_SHELL_FLIGHT, () => blast(battle, unit, atk, x, y), { owner: unit });
if (i + 1 < n) battle.after(LEMUEN_SHELL_INTERVAL, () => fire(i + 1), { owner: unit });
};
if (n > 0) fire(0);
};
// S2 归乡邀约: aimed snipe at a wanted enemy — ATK scale ramps main → fin by ex every interval (at most trig_cnt
// steps, aim_duration s), fired early once ATK × scale > the target's HP + DEF; ignores dodge; one bullet per aim
const aimS = {
@@ -463,7 +501,7 @@ function lemuen(bb, chess, def) {
onTick({ battle, unit, skill, dt }) {
const m = unit.mem;
if (!m.lemLocks) return;
for (const L of m.lemLocks) if (L.e.alive && !L.e.hidden) { L.x = L.e.x; L.y = L.e.y; }
for (const L of m.lemLocks) markOf(L);
if (!unit.canAct) return;
m.lemAcc += dt;
if (m.lemAcc + 1e-9 < aim) return;
@@ -477,6 +515,7 @@ function lemuen(bb, chess, def) {
if (skill.active && skill.ammoLeft <= 0) skill.end('ammo');
},
onEnd({ battle, unit, reason }) {
// the shells follow one by one after the end (bombard); knocked out / withdrawn mid-lock ('death'): none
const locks = unit.mem.lemLocks || [];
unit.mem.lemLocks = null;
if (reason !== 'death' && unit.alive) bombard(battle, unit, locks);
+20 -8
View File
@@ -4,7 +4,9 @@
// PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait-text / trait-blackboard tunables (TUNE[sub]) →
// data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides.
// Profile fields:
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none' projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none'
// projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'|'boomerang' ('boomerang': out to the target and back to
// the thrower, ai.js throwBoomerang) boomerang bool (回环射手: keeps 'boomerang')
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
// allInRange bool splashRadius tiles splashScale (× damage for splash victims)
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
@@ -175,6 +177,18 @@ const installHunter = (battle, unit) => {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.ammo = max; }, { owner: unit });
};
/**
* 回环射手 (loopshooter) trait "持有回旋投射物时才能够攻击(投射物需要时间回收)": its projectile is a boomerang that
* flies to the target, hits on arrival and flies back to the thrower (ai.js throwBoomerang, speeds in constants.js);
* the thrower attacks only while it holds its boomerang — every one it threw must be caught first (PRTS 跃跃 S2 note
* "必须回收全部回旋投掷物才可以进行下一次攻击") — and once its attack cooldown is ready, so the real interval is the longer
* of the two ("实际攻击间隔会受投掷物的实际飞行时间影响产生浮动"). A (re)deployed thrower holds a fresh one.
*/
const installLoopshooter = (battle, unit) => {
unit.trait.boomerangsOut = 0;
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.boomerangsOut = 0; }, { owner: unit });
};
const installTactician = (battle, unit) => {
const spawn = () => {
if (!unit.alive || !unit.deployed) return;
@@ -266,13 +280,8 @@ export const SUB = Object.freeze({
canAttack: (battle, unit) => (unit.trait.ammo ?? 8) > 0,
dmgMul: (b, u) => u.profile.ammoScale ?? 1.2,
afterAttack: (battle, unit) => { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 8) - 1); } }),
loopshooter: P({ projectile: 'lob',
afterAttack: (battle, unit, targets) => {
const t = targets[0];
if (!t) return;
const d = Math.hypot(t.x - unit.x, t.y - unit.y);
unit.atkCd = Math.max(unit.atkCd, (2 * d) / 10);
} }),
loopshooter: P({ projectile: 'boomerang', boomerang: true, install: installLoopshooter,
canAttack: (battle, unit) => !(unit.trait.boomerangsOut > 0) }),
reaperrange: P({ allInRange: true,
dmgMul: (battle, unit, target) => {
const grid = unit.profile.frontGrid;
@@ -456,6 +465,9 @@ export function resolveProfile(def, kitTrait = null) {
}
if (def.targetPriority) p.priority = PRIORITY_ALIASES[def.targetPriority] ?? def.targetPriority;
if (def.splashRadius != null && def.splashRadius > 0) p.splashRadius = def.splashRadius;
// a boomerang thrower (回环射手) always throws its boomerang: the data's generic ranged projectile ('arrow', a
// build-data default) would turn the out-and-back flight into a plain shot
if (p.boomerang && p.attack === 'ranged') p.projectile = 'boomerang';
if (def.type === 'token') {
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
if (def.stats.atk <= 0) p.noAttack = true;
+28 -1
View File
@@ -5,7 +5,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { ELEMENT_GAUGE_MAX } from '../../server/sim/constants.js';
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
import { sortEnemyTargets } from '../../server/sim/targeting.js';
const ds = getDefaultSource();
@@ -301,6 +301,33 @@ test('1_09 跃跃: 乐趣加倍 throws cnt boomerangs per attack, ATK +atk; 戏
done(h2);
});
test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 tiles/s); faster than its flight she waits for the catch; 乐趣加倍 hits land together', () => {
const id = 'chess_char_1_09_a', bb = bbOf(id);
const flight = (d) => d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
// ASPD +200 (interval 0.333 s) at 3 tiles: the out-and-back flight (1.0 s) sets the pace
const h = run({
defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }], enemies: [{ key: 'e', pos: [10, 7] }],
setup(b) { b.on('deploy', ({ unit }) => { if (unit.kind === 'op') b.addBuff(unit, { key: 'test:aspd', mods: { aspd: 200 } }); }); },
});
const u = h.unit(id);
h.run(6);
assert.ok(u.s.interval < flight(3) / 2);
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
assert.ok(at.length >= 4 && at.length <= 7, `${at.length} attacks in 6 s`);
for (let i = 1; i < at.length; i++) assert.ok(Math.abs(at[i] - at[i - 1] - flight(3)) <= 2 * h.TICK, `gap ${(at[i] - at[i - 1]).toFixed(3)} ≈ ${flight(3)}`);
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
done(h);
// 乐趣加倍: cnt boomerangs on the same path — cnt hits at the same moment, then one catch
const h2 = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 7] }] });
const u2 = h2.unit(id);
h2.run(5);
assert.ok(u2.skill.active);
const byT = new Map();
for (const c of dealt(h2, u2, (x) => x.dmg.isSkill)) byT.set(c.t, (byT.get(c.t) ?? 0) + 1);
assert.ok(byT.size >= 3 && [...byT.values()].every((n) => n === bb.cnt), `hits per landing ${[...byT.values()]}`);
done(h2);
});
test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
+91 -6
View File
@@ -72,7 +72,11 @@ test('盟约·辅助干员 (elite): element damage vs elite enemies uses the mod
assert.ok(near(el.amount, u.s.atk * u.def.talents[0].bb.ep_damage_ratio_boss, 1e-3));
});
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 % centre)', () => {
/** 蕾缪安 S3 fx of the battle: [{ kind, x, y, ...extra }] for 'lock' / 'bombardShell' / 'bombard'. */
const lemFx = (h, kind) => h.eventsOf('fx').filter((e) => e[1] === kind).map((e) => ({ x: e[2], y: e[3], ...e[4] }));
const SHELL_IV = 0.3; // PRTS S3 note: "以0.3s为间隔" (not in the blackboard)
test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s, then the shells land one by one every 0.3 s (360 % centre)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
@@ -85,13 +89,94 @@ test('蕾缪安: S3 locks 5 (+1 talent) targets every 0.5 s then bombards (360 %
const start = h.b.time;
assert.ok(u.findBuff('lemuen:extradition'), 'ATK +10 % after 20 s');
assert.ok(h.runUntil(() => !u.skill.active, 20));
const end = h.b.time;
const ammo = h.hooksOf('ammoUsed').filter((c) => c.unit === u);
assert.equal(ammo.length, bb['attack@trigger_time'] + t1.add_count);
assert.ok(Math.abs(h.b.time - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
const n = bb['attack@trigger_time'] + t1.add_count;
assert.equal(ammo.length, n);
assert.ok(Math.abs(end - start - (ammo.length - 1) * bb['attack@aim_interval']) < 0.1, 'one lock every 0.5 s');
assert.equal(lemFx(h, 'lock').length, n, 'one lock mark per bullet');
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 0, 'nothing lands all at once at the end');
const atk = u.s.atk;
h.run(SHELL_IV * (n + 1));
// one shell every 0.3 s in lock order, each on a random point within ±emit_offset of its (stationary) target
const shells = lemFx(h, 'bombardShell');
assert.deepEqual(shells.map((s) => s.i), [...Array(n).keys()], 'shells fired one by one, in lock order');
const spread = bb['attack@emit_offset'] + 0.01;
for (const s of shells) {
assert.ok(Math.abs(s.x - 5) <= spread && Math.abs(s.y - 10) <= spread, `aimed within ±${bb['attack@emit_offset']} of the lock (${s.x}, ${s.y})`);
assert.equal(s.id, u.id);
assert.equal(s.r, bb['attack@dist_2']);
assert.ok(s.t > 0 && s.t <= SHELL_IV + 1e-9, `flight ${s.t} s`);
}
assert.ok(new Set(shells.map((s) => `${s.x},${s.y}`)).size > 1, 'the aim points are spread (seeded rng)');
const bombs = h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard'));
assert.equal(bombs.length, ammo.length, 'one bomb per lock');
assert.ok(near(bombs[0].amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 %');
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < h.b.time - 0.1).length, 0, 'no normal attacks while locking');
assert.equal(bombs.length, n, 'one hit per shell on the single enemy (limited_hit_time 1)');
bombs.forEach((b, i) => assert.ok(Math.abs(b.t - end - SHELL_IV * (i + 1)) <= 2 / 30 + 1e-9, `shell ${i} lands at +${(b.t - end).toFixed(3)} s`));
assert.equal(lemFx(h, 'bombard').length, n, 'an explosion fx per shell');
for (const b of bombs) assert.ok(near(b.amount, atk * bb['attack@proj_atk_scale_1'], 1e-3), 'centre damage 360 % (the ATK when the skill ended)');
assert.equal(h.dmg.filter((d) => d.src === u.id && d.attack && d.t > start && d.t < end - 0.1).length, 0, 'no normal attacks while locking');
checkInvariants(h.b);
});
test('蕾缪安: S3 lock marks follow a walking target and stay where it died; the outer ring deals 240 %', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [9, 6], end: [9, 2], checkpoints: [] } }, { key: 'enemy_dummy', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_01_a');
const bb = u.def.skill.bb;
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.ok(h.runUntil(() => !u.skill.active, 10));
const walker = enemyAt(h, 'enemy_walker'), dummyE = enemyAt(h, 'enemy_dummy');
const locks = lemFx(h, 'lock');
assert.deepEqual(locks.slice(0, 2).map((l) => l.id).sort(), [walker.id, dummyE.id].sort(), 'unlocked targets first, then a new round');
// the walker's shells aim at where it is when each is fired (it keeps walking), not where it was locked
const firstLock = locks.find((l) => l.id === walker.id);
const at0 = { x: walker.x, y: walker.y };
assert.ok(Math.abs(at0.x - firstLock.x) > 0.6, `the walker moved on since its lock (${firstLock.x} → ${at0.x})`);
const order = locks.map((l) => l.id);
const spread = bb['attack@emit_offset'] + 0.01;
const shell0 = lemFx(h, 'bombardShell')[0];
if (order[0] === walker.id) assert.ok(Math.abs(shell0.x - at0.x) <= spread && Math.abs(shell0.y - at0.y) <= spread, 'first shell on the walker\'s current spot');
// it dies before its later shells are fired: they fall where it died
h.b.kill(walker);
const died = { x: walker.x, y: walker.y };
h.run(SHELL_IV * (order.length + 1));
const shells = lemFx(h, 'bombardShell');
assert.equal(shells.length, order.length);
shells.forEach((s, i) => {
if (order[i] !== walker.id || i === 0) return;
assert.ok(Math.abs(s.x - died.x) <= spread && Math.abs(s.y - died.y) <= spread, `shell ${i} on the spot the walker left (${s.x}, ${s.y})`);
});
// the dummy one row below the walker's line: centre (≤ dist_1) or outer ring (≤ dist_2) damage per shell
const onDummy = h.dmg.filter((d) => d.src === u.id && d.tgt === dummyE.id && d.tags.includes('bombard'));
assert.ok(onDummy.every((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_1'], 1e-3) || near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)));
assert.ok(onDummy.some((d) => near(d.amount, u.s.atk * bb['attack@proj_atk_scale_2'], 1e-3)), 'a shell on the walker line reaches it with the outer ring (240 %)');
checkInvariants(h.b);
});
test('蕾缪安: knocked out after S3 ended, the shell in the air still lands and the rest are dropped', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_6_01_a');
assert.ok(h.runUntil(() => u.skill.active, 10));
assert.ok(h.runUntil(() => !u.skill.active, 10));
assert.equal(lemFx(h, 'bombardShell').length, 1, 'the first shell is fired as the skill ends');
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
h.run(3);
assert.equal(lemFx(h, 'bombardShell').length, 1, 'no shell after she left the field');
assert.equal(h.dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')).length, 1, 'the fired shell landed');
// knocked out while locking: no bombardment at all
const h2 = battle({ units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3, carryState: { sp: 99 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }] });
const u2 = h2.unit('chess_char_6_01_a');
assert.ok(h2.runUntil(() => u2.skill.active && lemFx(h2, 'lock').length >= 2, 10));
h2.b.dealDamage(null, u2, { amount: 1e9, type: 'true' });
h2.run(3);
assert.equal(lemFx(h2, 'bombardShell').length, 0);
assert.equal(h2.dmg.filter((d) => d.src === u2.id && d.tags.includes('bombard')).length, 0);
checkInvariants(h.b);
});
+80 -2
View File
@@ -4,7 +4,9 @@
// m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a
// synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items
// locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from
// local art, 标准 = 战场#01 only.
// local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the shared picking
// rule / drag target, the live LP, the detail card order and the static game data.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -16,7 +18,12 @@ import { NET_DEFAULTS } from '../server/net.js';
import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR } from '../shared/constants.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../server/sim/constants.js';
import { SUB } from '../server/sim/professions.js';
import { BODY_H, PROBE_MARGIN } from '../public/js/render/pick.js';
import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.js';
import { RETRY_DELAYS_MS } from '../public/js/data.js';
import { DATA, makeMatch } from './match/harness.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -173,3 +180,74 @@ test('solo pause: g.pause {on} is solo-only and m.public.paused follows (code)
assert.match(README, /暂停(独立模拟)/);
assert.match(PLAYING, /同盟模拟的作战不能暂停/);
});
test('temp overflow (user playtest #3 item 3): kept through the round start, resolved at the deadline of the first prep its player can act in (code) — every doc says so', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 4343, fake: true }).start();
const m = h.m;
const ps = h.ps('p_0');
// distinct plain equipment (two identical ones would merge)
const plain = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id);
assert.ok(plain.length > ps.hand.length);
h.toPrep(1);
assert.ok(h.drive(() => m.phase === PHASE.SETTLE && m.round === 1), 'SETTLE of R1');
ps.hand.fill(null);
plain.slice(0, ps.hand.length).forEach((id, i) => { ps.hand[i] = ps.newPiece('item', id); });
const late = ps.newPiece('item', plain[ps.hand.length]);
assert.equal(ps.stow(late), 'temp', 'a gain after the battle with a full hand overflows into temp');
h.toPrep(2);
assert.ok(ps.temp.includes(late), 'not wiped at the round start');
assert.equal(ps.tempDue(late), ps.prepsEnded, 'due at the end of this prep — the first one its player can act in');
assert.equal(ps.privateView().canReady, false, 'it blocks Ready');
assert.ok(h.drive(() => m.phase === PHASE.COMBAT && m.round === 2, { ready: false }), 'the prep deadline passes');
assert.ok(!ps.temp.includes(late), 'resolved at that deadline');
m.dispose();
assert.ok(!/temp hand wiped/.test(DESIGN), 'DESIGN §6.1: the round start no longer wipes temp');
assert.match(DESIGN, /PlayerState\.tempDue/);
assert.ok(!/temp wiped \(reward offers/.test(META) && !/wiped at the round start/.test(META), 'META §1 / rules');
assert.match(META, /PlayerState\.tempDue/);
assert.ok(!/下一回合开始时自动销毁/.test(PLAYING), 'PLAYING §3: no round-start destruction');
assert.match(PLAYING, /保留到\*\*下一个休整期\*\*/);
});
test('回环射手 boomerang and 蕾缪安 S3 shells (user playtest #3 items 4–5): code and SIM / DESIGN agree', () => {
assert.equal(PROJECTILE_SPEEDS.boomerang, 15, 'PRTS 跃跃: out 15');
assert.equal(BOOMERANG_RETURN_SPEED, 3.75, 'PRTS 跃跃: back 3.75');
assert.equal(SUB.loopshooter.projectile, 'boomerang');
assert.equal(typeof SUB.loopshooter.canAttack, 'function', 'attacks only while holding the boomerang');
assert.match(SIM, /boomerang 15 out,\s*3\.75 back/);
assert.ok(!/next attack waits for the boomerang \(2 × distance \/ 10 s\)/.test(SIM), 'SIM §8: the old lob rule is gone');
assert.match(SIM, /`none\|arrow\|bolt\|bomb\|lob\|orb\|drone\|enemy\|boomerang\|chain\|chainHeal`/);
assert.match(DESIGN, /BOOMERANG_RETURN_SPEED/);
// 蕾缪安: one shell every 0.3 s after the skill (PRTS), fx 'bombardShell' then 'bombard' — the kit's constants
const kit = readFileSync(join(ROOT, 'server/sim/content/kits/tier6.js'), 'utf8');
assert.match(kit, /const LEMUEN_SHELL_INTERVAL = 0\.3;/);
assert.match(kit, /battle\.fx\('bombardShell'/);
assert.match(DESIGN, /'bombardShell' \{x, y, id: shooter, r, t: flight game s, i\}/);
assert.match(DESIGN, /ONE shell every 0\.3 s in lock order/);
assert.match(SIM, /S3 礼炮·强制追思/);
});
test('picking, drag target, lost models, live LP, detail card order, static game data (user playtest #3): code and DESIGN §17 agree', () => {
assert.equal(BODY_H, 1.27, 'median chibi height (tiles)');
assert.equal(PROBE_MARGIN, 0.5);
assert.match(DESIGN, /`PROBE_MARGIN` 0\.5 tile/);
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(app, /export const TOUCH_LIFT_TILES = 0\.6;/);
assert.match(DESIGN, /`TOUCH_LIFT_TILES` = 0\.6 tile above the finger/);
assert.match(DESIGN, /render\/pick\.js/);
assert.equal(SPINE_EVICT_DELAY_MS, 1000);
assert.equal(SPINE_QUIET_DELAY_MS, 3000);
assert.match(DESIGN, /`SPINE_EVICT_DELAY_MS` \(1 s\)/);
assert.match(DESIGN, /`SPINE_QUIET_DELAY_MS` \(3 s\)/);
assert.deepEqual([...RETRY_DELAYS_MS], [600, 2000]);
assert.match(DESIGN, /after 600 ms and 2 s \(`RETRY_DELAYS_MS`\)/);
const panel = readFileSync(join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8');
assert.match(panel, /CHESS_SECTIONS = Object\.freeze\(\['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents'/);
assert.match(DESIGN, /`detailPanel\.js CHESS_SECTIONS`\): header .* → \*\*特质\*\*/);
assert.match(panel, /export function garrisonTypeIconKey\(garrison\)/);
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
});
+14 -2
View File
@@ -264,7 +264,7 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; a full hand sends the elite to a freed board tile', () => {
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) {
@@ -295,7 +295,19 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) assert.ok(tiles.includes(ps.find(elite.uid).key), 'full hand outside PREP: the elite took a freed board tile, not temp');
if (onBoard) {
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp');
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row');
assert.equal(ps.privateView().canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
const [r, c] = tiles[0].split(',').map(Number);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile');
assert.ok(ps.tempEmpty && ps.privateView().canReady);
} else {
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots');
}
assert.ok(ps.tempEmpty);
checkInvariants(m);
// it expires at the end of that prep like any other offer
+76 -1
View File
@@ -1,7 +1,8 @@
// The browser battle runner (public/js/battle/runner.js) under Node with a fake socket, a manual clock and manual
// animation frames: pacing (60 ticks per real second at 2×, ≤ 8 per frame), the b.snap / b.ev feed and the field meta,
// b.progress / b.result when authoritative (valid protocol frames, the same result as the server's simulation),
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing.
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing, the live
// leak count of normal fields (state().leaks, user playtest #3 item 2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBattleRunner, ticksPerFrameCap } from '../../public/js/battle/runner.js';
@@ -329,3 +330,77 @@ test('solo pause: m.public.paused freezes every local battle clock (no ticks, no
assert.ok(Math.abs(e.battle.tickCount - (t1 + 60)) <= 2, `resumed on the same clock, no catch-up burst (${e.battle.tickCount})`);
r.runner.dispose();
});
/** realStart() with an empty board: every enemy of the wave walks into the blue gate. */
function leakingStart(seed = 7307) {
const start = realStart(seed);
const spec = JSON.parse(JSON.stringify(start.spec));
for (const p of spec.players) p.units = [];
return { ...start, spec };
}
test('live leaks (user playtest #3 item 2): state().leaks carries the own field\'s counted leaks as they happen — authoritative or display — published in the frame of each leak; the settle rule\'s count at the end', async () => {
const start = leakingStart();
const r = rig();
const seen = [];
r.store.subscribe((s, prev) => {
const n = s.match.battle && s.match.battle.leaks ? s.match.battle.leaks[start.fieldId] : undefined;
const was = prev.match.battle && prev.match.battle.leaks ? prev.match.battle.leaks[start.fieldId] : undefined;
if (n !== was && n !== undefined) seen.push(n);
});
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.deepEqual(r.store.get().match.battle.leaks, { [start.fieldId]: 0 }, 'published with the field, 0 before any leak');
// step frame by frame: whenever the battle recorded a counted leak, the store has it after that same frame
for (let i = 0; i < 200 * 60 && !e.done; i++) {
r.advance(1000 / 60);
const now = specMod.battleProgress(e.battle).leaks;
assert.equal(r.store.get().match.battle.leaks[start.fieldId], now, `frame ${i}: the store follows the battle`);
}
assert.ok(e.done, 'finished');
const res = e.battle.result();
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
assert.ok(counted >= 3, `the empty board leaks (${counted})`);
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'the settle rule\'s count (Match.settle: counted !== false)');
assert.equal(seen[seen.length - 1], counted, 'the last publish carries the final count');
assert.ok(seen.every((n, i) => i === 0 || n > seen[i - 1]), `one publish per new count, increasing (${seen})`);
r.runner.dispose();
// a display replica of the own field (the server simulates it: reconnect after a takeover) counts the same
const r2 = rig();
r2.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 30 });
await r2.settle();
const e2 = r2.runner._entries.get(start.battleId);
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], specMod.battleProgress(e2.battle).leaks, 'counted during the silent catch-up');
for (let i = 0; i < 200 && !e2.done; i++) r2.advance(1000, 50);
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted);
assert.equal(r2.net.sent.length, 0, 'still never reports');
// the round moves on: the next prep drops the battles and their counts
r2.store.patch('match', { public: { phase: 'SETTLE' } });
assert.equal(r2.store.get().match.battle.leaks[start.fieldId], counted, 'kept through SETTLE (the top bar resets on the settled m.private)');
r2.store.patch('match', { public: { phase: 'PREP' } });
assert.equal(r2.store.get().match.battle, null);
r2.runner.dispose();
});
test('live leaks: a boss field is not counted (the merged team LP moves through b.pool); b.end forced publishes the final count', async () => {
const start = leakingStart(7308);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
r.advance(1500);
r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'timeout' });
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.ok(e.done);
const res = e.battle.result();
const counted = Object.values(res.perPlayer).reduce((n, pp) => n + pp.leaked.filter((l) => l && l.counted !== false).length, 0);
assert.equal(r.store.get().match.battle.leaks[start.fieldId], counted, 'timeout leaks (the enemies left on the field) are counted at once');
r.runner.dispose();
const r2 = rig();
r2.net.emit('b.start', { ...start, kind: 'boss', fieldId: 'b1', battleId: `${start.battleId}x` });
await r2.settle();
assert.deepEqual(r2.store.get().match.battle.leaks, {}, 'only normal fields');
r2.runner.dispose();
});
+218
View File
@@ -0,0 +1,218 @@
// 临时整备区 (temp overflow) lifetime (PlayerState tempDue, research 06 §4.3 "直到溢出情况排除才可开始进行作战" /
// "处于临时整备区的调度资源,在进入下一回合后会自动销毁"): a temp piece is resolved at the deadline of the first prep in
// which its player could act on it. What overflows after that — battle-result grants, SETTLE merges, returned
// equipment, <休整期结束时> grants after Ready — survives the round start and is shown and usable in the next prep.
// FakeBattle; the rule auditor (server/match/audit.js) watches every match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { MetaRegistry } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { attachAudit } from '../../server/match/audit.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
/** Plain equipment (no equip / merge side effects): fillers for the hand and the pieces granted by the tests. */
const PLAIN = Object.values(DATA.items)
.filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive')
.map((i) => i.itemId ?? i.id).filter(Boolean);
const GRANTS = PLAIN.slice(-3);
const FILL = PLAIN.slice(0, 10);
/**
* Co-op match (2 humans: timed prep, FakeBattle) in PREP R1 with p_0's hand full of plain items and one carrier on the
* board; `grants(ctx)` runs for p_0 on the hook `hook` (registry global). Audited from the start.
*/
function setup({ seed = 1500, hook = 'onBattleResult', grants = (ctx) => { for (const id of GRANTS.slice(0, 2)) ctx.grantItem(id); } } = {}) {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let armed = false;
reg.global('temp_test', { [hook](ctx) { if (armed && ctx.playerId === 'p_0') { armed = false; grants(ctx); } } });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed, fake: true, registry: reg }).start();
const audit = attachAudit(h.m);
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const carrierId = chessOfTier(2, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const carrier = give(m, ps, carrierId, 'board', legalTileFor(m, ps, carrierId));
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, FILL[i]);
return { h, m, ps, audit, carrier, arm: () => { armed = true; } };
}
test('equipment granted after the battle overflows into temp, survives the round start and is usable in the next prep', () => {
const { h, m, ps, audit, carrier, arm } = setup();
arm();
h.drive(() => m.phase === PHASE.SETTLE && m.round === 1);
const got = ps.temp.filter(Boolean).map((p) => p.id);
assert.deepEqual(got.sort(), GRANTS.slice(0, 2).sort(), 'the battle-result grants overflowed into temp (full hand)');
h.toPrep(2);
const kept = ps.temp.filter(Boolean);
assert.deepEqual(kept.map((p) => p.id).sort(), GRANTS.slice(0, 2).sort(), 'not wiped at the round start');
const view = ps.privateView();
assert.deepEqual(view.temp.filter(Boolean).map((v) => v.uid).sort(), kept.map((p) => p.uid).sort(), 'shown in the temp row');
assert.equal(view.canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }, 'Ready is blocked until the overflow is cleared');
// usable: equip one straight from temp, destroy the other
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: kept[0].uid, targetUid: carrier.uid }), { ok: true });
assert.ok(carrier.items.some((it) => it.uid === kept[0].uid), 'equipped from temp');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: kept[1].uid }), { ok: true });
assert.ok(ps.tempEmpty && ps.privateView().canReady);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
checkInvariants(m);
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
/** A tier-1 chess of this match's pool without summons (a plain card for the grants). */
const plainChess = (m) => chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id) && m.pool.left(id) > 0);
test('a chess granted after the battle can be moved into a freed hand slot; what is left at the next deadline is resolved', () => {
let T = null;
const { h, m, ps, audit, arm } = setup({ seed: 1501, grants: (ctx) => { ctx.grantChess(T); ctx.grantItem(GRANTS[2]); } });
T = plainChess(m);
const cap = m.pool.cap(T);
arm();
h.toPrep(2);
const chess = ps.temp.find((p) => p && p.kind === 'chess');
const item = ps.temp.find((p) => p && p.kind === 'item');
assert.ok(chess && chess.id === T && item && item.id === GRANTS[2], 'both grants waited in temp');
assert.equal(m.pool.left(T), cap - 1, 'the temp chess holds its pool copy');
// free a hand slot (destroy a filler) and take the chess into it
const filler = ps.hand.findIndex((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: ps.hand[filler].uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: chess.uid, to: { area: 'hand', idx: filler } }), { ok: true });
assert.equal(ps.find(chess.uid).area, 'hand');
// the item stays unhandled: the R2 deadline (co-op prep is timed) destroys it
const p1 = h.ps('p_1');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
assert.ok(m.deadline > h.sched.now());
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(p1.ready && ps.ready);
assert.ok(ps.tempEmpty, 'resolved at the deadline of the prep it was shown in');
assert.ok(!ps.find(item.uid), 'the item was destroyed');
assert.equal(ps.find(chess.uid).area, 'hand', 'the chess taken out of temp is kept');
assert.equal(m.pool.left(T), cap - 1);
checkInvariants(m);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('a chess still in temp at the next deadline is sold back: its pool copy returns', () => {
let T = null;
const { h, m, ps, audit, arm } = setup({ seed: 1502, grants: (ctx) => { ctx.grantChess(T); } });
T = plainChess(m);
const cap = m.pool.cap(T);
arm();
h.toPrep(2);
assert.ok(ps.temp.some((p) => p && p.id === T));
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(ps.tempEmpty);
assert.equal(m.pool.left(T), cap, 'copy back in the shared pool');
checkInvariants(m);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('<休整期结束时> grants after Ready overflow into temp and are kept for the next prep (never wiped unseen)', () => {
const { h, m, ps, audit, arm } = setup({ seed: 1503, hook: 'onPrepEnd' });
arm();
// both players ready with empty temps → the prep ends → onPrepEnd grants overflow into p_0's temp
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
h.run(() => m.phase !== PHASE.PREP);
assert.equal(ps.temp.filter(Boolean).length, 2, 'the prep-end grants are in temp');
const uids = ps.temp.filter(Boolean).map((p) => p.uid).sort();
assert.ok(ps.temp.filter(Boolean).every((p) => ps.tempDue(p) === ps.prepsEnded), 'due at the next prep');
h.toPrep(2);
assert.deepEqual(ps.temp.filter(Boolean).map((p) => p.uid).sort(), uids, 'kept through COMBAT, SETTLE and the round start');
assert.equal(ps.privateView().canReady, false);
// left alone they expire at this prep's deadline
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(ps.tempEmpty);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('an overflow while ready waits for the next prep; un-readying makes it due at this prep', () => {
for (const unready of [false, true]) {
const { h, m, ps, audit } = setup({ seed: 1504 });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
// a passive gain while p_0 is ready (a teammate's effect, a delayed grant …)
const got = ps.acquireItem(GRANTS[0], { source: 'test' });
assert.equal(ps.find(got.uid).area, 'temp');
assert.equal(ps.tempDue(got), ps.prepsEnded + 1, 'the player could not act on it any more: due at the next prep');
if (unready) {
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
assert.equal(ps.tempDue(got), ps.prepsEnded, 'un-ready: it can be handled now, so it is due at this prep');
}
m.handle('p_1', { t: 'g.ready', ready: true });
// still ready: everybody is, so the prep ends at once; un-readied: the deadline ends it
if (unready) h.sched.advance(m.deadline - h.sched.now() + 1);
else h.run(() => m.phase !== PHASE.PREP);
assert.notEqual(m.phase, PHASE.PREP);
if (unready) assert.ok(!ps.find(got.uid), 'resolved at this deadline');
else assert.equal(ps.find(got.uid)?.area, 'temp', 'kept for the next prep');
checkInvariants(m);
assert.deepEqual(audit.violations, [], `${unready}:\n${audit.violations.join('\n')}`);
m.dispose();
}
});
test('an overflow during the prep before Ready expires at that prep\'s deadline (and is due now)', () => {
const { h, m, ps, audit } = setup({ seed: 1505 });
const got = ps.acquireItem(GRANTS[1], { source: 'test' });
assert.equal(ps.find(got.uid).area, 'temp');
assert.equal(ps.tempDue(got), ps.prepsEnded);
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(!ps.find(got.uid) && ps.tempEmpty);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
test('the rule auditor flags a temp piece kept past its prep deadline (engine regression guard)', () => {
const { h, m, ps, audit } = setup({ seed: 1506 });
ps.resolveTemp = () => {}; // simulated engine bug: the deadline never resolves temp
ps.acquireItem(GRANTS[1], { source: 'test' });
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(audit.violations.some((v) => /p_0: temp piece .* survived its prep end/.test(v)), audit.violations.join('\n'));
assert.ok(!audit.violations.some((v) => v.includes('p_1')));
m.dispose();
});
test('a bot clears the temp contents it finds at the start of its prep and readies before the deadline', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let armed = true;
reg.global('temp_test', {
onBattleResult(ctx) {
if (!armed || ctx.playerId !== 'ai_0') return;
armed = false;
for (let i = 0; i < 12; i++) ctx.grantItem(PLAIN[i % PLAIN.length]);
},
});
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 1507, fake: true, registry: reg }).start();
const audit = attachAudit(h.m);
const m = h.m;
h.toPrep(2);
const bot = h.ps('ai_0');
assert.ok(!bot.tempEmpty, 'the bot starts its prep with an overflow');
const deadline = m.deadline;
assert.ok(h.run(() => bot.ready || m.phase !== PHASE.PREP));
assert.ok(bot.ready && h.sched.now() < deadline, 'ready before the deadline');
assert.ok(bot.tempEmpty);
checkInvariants(m);
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
});
+143 -3
View File
@@ -10,7 +10,7 @@ import {
avatarUrl, portraitUrl, enemyIconUrl, tokenAvatarUrl, bondIconUrl, bandIconUrl, itemIconUrl, skillIconUrl, uiUrl,
profIconUrl, subProfIconUrl, spineEntry, hasBackSpine, unitPictureUrl, bgmEntry, sfxUrl, unitSfxUrl, baseCharId,
validSpine, RefLru, createAssets, unloadSpineData, spinePages, spineDataWeight, SPINE_WEIGHT_MIN, SPINE_IDLE_BYTES,
SPINE_IDLE_GRACE_MS,
SPINE_IDLE_GRACE_MS, SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS,
} from '../../public/js/assets.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -295,6 +295,143 @@ describe('RefLru', () => {
await p;
assert.deepEqual(unloads, ['a']);
});
// user playtest #3 item 1 (invisible operator models): an unload is asynchronous; a new load of the same key while
// it is in flight must not get the doomed value
test('a load of a key whose unload is still in flight waits for that unload (never the doomed value)', async () => {
const order = [];
let finishUnload;
let n = 0;
const lru = new RefLru({ max: 1,
load: async (k) => { order.push(`load ${k}`); return `${k}#${++n}`; },
unload: (k, v) => { order.push(`unload ${v}`); return new Promise((r) => { finishUnload = () => { order.push(`unloaded ${v}`); r(); }; }); } });
assert.equal(await lru.acquire('a'), 'a#1');
await lru.acquire('b');
lru.release('a'); // over max: a goes, its unload is in flight
assert.ok(lru.unloading('a') && lru.stats().unloading === 1);
const again = lru.acquire('a'); // same task: taken again
await tick();
assert.deepEqual(order, ['load a', 'load b', 'unload a#1'], 'the new load waits');
finishUnload();
assert.equal(await again, 'a#3', 'a fresh value once the old one is gone (b was #2)');
assert.deepEqual(order.slice(3), ['unloaded a#1', 'load a']);
await tick();
assert.equal(lru.unloading('a'), false);
// an unloader that throws or returns nothing never blocks a later load
const lru2 = new RefLru({ max: 1, load: async (k) => k, unload: () => { throw new Error('x'); } });
await lru2.acquire('a'); await lru2.acquire('b'); lru2.release('a');
assert.equal(await lru2.acquire('a'), 'a');
});
test('evictDelay: releases schedule one eviction pass; a scene rebuilt in one go keeps what it takes back', async () => {
const c = clock(), unloads = [];
let loads = 0;
const lru = new RefLru({ max: 2, load: async (k) => { loads++; return k; }, unload: (k) => unloads.push(k), evictDelay: 500, now: c.now, timers: c.timers });
for (const k of ['a', 'b', 'c']) await lru.acquire(k);
// every view destroyed, then the next scene's views built (same task): nothing is dropped meanwhile
for (const k of ['a', 'b', 'c']) lru.release(k);
for (const k of ['a', 'b']) await lru.acquire(k);
assert.deepEqual(unloads, []);
assert.equal(c.pending.length, 1, 'one pass pending');
c.advance(510);
assert.deepEqual(unloads, ['c'], 'the pass drops only what nobody took back');
assert.equal(loads, 3, 'a and b were never reloaded');
});
test('quietDelay: the instant zero of a scene switch is not "no scene"; a lasting one is', async () => {
const c = clock(), unloads = [];
const lru = new RefLru({ max: 100, load: async (k) => k, unload: (k) => unloads.push(k), weigh: () => 10,
maxIdleWeight: 100, quietWeight: 0, idleGrace: 5000, quietDelay: 3000, now: c.now, timers: c.timers });
await lru.acquire('bench'); await lru.acquire('op');
lru.release('bench'); // prep → battle: the bench model idles for the whole battle
c.advance(20000);
lru.release('op'); // battle → prep: nothing referenced for an instant …
assert.deepEqual(unloads, [], 'the budget of a shown scene applies (10 + 10 ≤ 100)');
await lru.acquire('bench'); await lru.acquire('op'); // … the prep views take their models back
c.advance(5000);
assert.deepEqual(unloads, []);
lru.release('bench'); lru.release('op'); // the view is gone for good (lobby)
c.advance(1000);
assert.deepEqual(unloads, [], 'not quiet long enough yet');
c.advance(2100);
assert.deepEqual(unloads, [], 'quiet, but both are still inside their grace');
c.advance(2000);
assert.deepEqual(unloads.sort(), ['bench', 'op'], 'the quiet budget empties the cache');
});
});
describe('Spine store: unload / reload of the same skeleton (user playtest #3 item 1)', () => {
const tick = () => new Promise((r) => setImmediate(r));
/**
* PIXI.Assets semantics that caused the invisible models: `unload` frees an asset a microtask after the call
* (Loader.unload awaits the cached promise, then deletes the cache entry and destroys the asset), and `load` of that
* URL meanwhile returns the cached — doomed — asset.
*/
function pixiAssets() {
const cache = new Map();
let seq = 0;
const loader = {
async unload(url) {
const hit = cache.get(url);
if (!hit) return;
const asset = await hit;
cache.delete(url);
asset.destroyed = true;
},
};
const Assets = {
loader,
load(url) {
if (!cache.has(url)) cache.set(url, Promise.resolve({ url, id: ++seq, destroyed: false, animations: [] }));
return cache.get(url);
},
unload(url) { return loader.unload(url); },
};
const prev = globalThis.PIXI;
globalThis.PIXI = { Assets, spine: {} };
return { Assets, restore: () => { if (prev === undefined) delete globalThis.PIXI; else globalThis.PIXI = prev; } };
}
test('the hazard: PIXI.Assets hands out an asset whose unload is in flight', async () => {
const f = pixiAssets();
try {
await f.Assets.load('/x.skel');
f.Assets.unload('/x.skel');
const again = await f.Assets.load('/x.skel');
await tick();
assert.equal(again.destroyed, true, 'the model a view would build from it has no textures');
} finally { f.restore(); }
});
test('battle → prep after a long battle: the bench models come back alive (defaults), with or without an eviction', async () => {
for (const eager of [false, true]) {
const f = pixiAssets();
try {
const c = clock();
// eager = the old timing (evict at once, no quiet delay): the bench model IS dropped at the instant zero —
// the re-acquire must then wait for its unload and load a fresh skeleton
const a = createAssets({ manifest: M, spineNow: c.now, spineTimers: c.timers, ...(eager ? { spineEvictDelay: 0, spineQuietDelay: 0 } : {}) });
const bench = a.spineEntry('char_010_chen'), op = a.spineEntry('char_002_amiya'), foe = a.spineEntry('enemy_1007_slime');
const bench0 = await a.spine.acquire(bench);
await a.spine.acquire(op);
// prep → battle: the prep views go (the bench model idles), the battle views take the board model and the foe
a.spine.release(bench); a.spine.release(op);
await a.spine.acquire(op); await a.spine.acquire(foe);
c.advance(SPINE_IDLE_GRACE_MS + 20000); // a long battle: the bench model idles past its grace
// battle → prep in one go (render/app.js enterPrepMode + setPrep): every battle view destroyed, prep views built
a.spine.release(op); a.spine.release(foe);
const [b1, o1] = await Promise.all([a.spine.acquire(bench), a.spine.acquire(op)]);
await tick(); await tick();
c.advance(SPINE_EVICT_DELAY_MS + SPINE_QUIET_DELAY_MS + 10);
await tick(); await tick();
assert.equal(b1.destroyed, false, `${eager ? 'eager' : 'default'}: the bench model is alive`);
assert.equal(o1.destroyed, false, `${eager ? 'eager' : 'default'}: the board model is alive`);
if (eager) assert.notEqual(b1, bench0, 'eager: dropped, then loaded afresh');
else assert.equal(b1, bench0, 'default: never dropped, never reloaded');
assert.equal(a.spine.stats().unloading, 0);
} finally { f.restore(); }
}
});
});
describe('spineDataWeight', () => {
@@ -418,12 +555,15 @@ describe('spine unload frees the atlas page images too', () => {
const f = fakePixi();
try {
const E = (id, pages) => ({ ...SP(id), textures: pages });
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1 });
const c = clock();
const a = createAssets({ manifest: M, loadSpine: async (e) => ({ animations: [], from: e.skel }), spineMax: 1, spineNow: c.now, spineTimers: c.timers });
const ea = E('a', ['/assets/spine/a.png', '/assets/spine/common.png']);
const eb = E('b', ['/assets/spine/b.png', '/assets/spine/common.png']);
await a.spine.acquire(ea);
await a.spine.acquire(eb);
a.spine.release(ea); // a evicted (max 1) while b still uses common.png
a.spine.release(ea); // a evicted (max 1) while b still uses common.png …
assert.deepEqual(f.calls.assets, [], '… in the eviction pass a moment later (SPINE_EVICT_DELAY_MS)');
c.advance(SPINE_EVICT_DELAY_MS + 10);
assert.deepEqual(f.calls.assets, ['/assets/spine/a.skel', '/assets/spine/a.atlas']);
assert.deepEqual(f.calls.loader, ['/assets/spine/a.png']);
a.spine.release(eb);
+47 -5
View File
@@ -77,28 +77,70 @@ describe('render engine in headless Chrome', { skip }, () => {
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
const { page, problems } = await open('scene=fx&panel=0', 1280, 720);
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s)
// cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots
// in flight (every projectile kind incl. the boomerang) and 蕾缪安's S3 locks (the gallery plays it at 'lock')
const kinds = await page.evaluate(async () => (await import('/js/render/fx.js')).FX_KINDS).then((k) => Object.keys(k).length);
await wait(Math.min(45000, kinds * 260 + 1500));
const seen = await page.evaluate(async (ms) => {
const seen = { projectiles: 0, particles: 0, locks: 0, auras: 0 };
const t0 = performance.now();
while (performance.now() - t0 < ms) {
const st = window.__demo.stats();
for (const k of Object.keys(seen)) seen[k] = Math.max(seen[k], st[k] || 0);
await new Promise((r) => setTimeout(r, 100));
}
return seen;
}, Math.min(45000, kinds * 260 + 1500));
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-fx.png') });
await page.close();
assert.deepEqual(problems, []);
assert.equal(st.mode, 'battle');
assert.ok(st.units >= 10, `units ${st.units}`);
assert.ok(seen.projectiles > 0 && seen.particles > 20 && seen.auras > 0, `projectiles, particles and a skill aura drawn (${JSON.stringify(seen)})`);
assert.ok(seen.locks >= 2, `蕾缪安's lock reticles shown (${JSON.stringify(seen)})`);
if (existsSync(path.join(ROOT, 'public/assets/local/map/autochess/tiles.json'))) assert.equal(st.boardArt, true, 'real board art composed');
});
test('stress: 120 units + FX keep the per-frame JS cost bounded', async () => {
/** fps / JS-per-frame (frame() + Pixi render EMAs, app.js stats) / particles over `ms`, sampled every 200 ms. */
async function frameCost(page, ms) {
return page.evaluate(async (ms) => {
const rows = [];
const t0 = performance.now();
while (performance.now() - t0 < ms) {
const s = window.__demo.stats();
rows.push([s.fps, s.cpuMs, s.renderMs, s.particles, s.projectiles]);
await new Promise((r) => setTimeout(r, 200));
}
const avg = (i) => rows.reduce((a, r) => a + r[i], 0) / rows.length;
return { fps: avg(0), cpuMs: avg(1), renderMs: avg(2), particles: avg(3), maxParticles: Math.max(...rows.map((r) => r[3])), projectiles: avg(4) };
}, ms);
}
const costLine = (c) => `fps ${c.fps.toFixed(1)} · JS ${c.cpuMs.toFixed(2)} + render ${c.renderMs.toFixed(2)} ms/frame · particles ${c.particles.toFixed(0)} (max ${c.maxParticles}) · shots ${c.projectiles.toFixed(1)}`;
test('stress: 120 units + FX keep the per-frame JS cost bounded', async (t) => {
const { page, problems } = await open('scene=stress&panel=0');
await wait(6000);
await wait(3000);
const cost = await frameCost(page, 3000);
const st = await page.evaluate(() => window.__demo.stats());
await page.screenshot({ path: path.join(OUT, 'render-stress.png') });
await page.close();
t.diagnostic(`stress 1920×1080: ${costLine(cost)}`);
assert.deepEqual(problems, []);
assert.equal(st.units, 120);
assert.ok(st.impostor >= 2, 'crowded field switches to impostor rendering');
assert.ok(st.cpuMs + st.renderMs < 16, `JS per frame ${st.cpuMs} + ${st.renderMs} ms`);
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
});
test('crowded boss battle: the FX keep the per-frame JS cost bounded', async (t) => {
const { page, problems } = await open('scene=boss-m02&t=16&panel=0');
await wait(2500);
// t ≈ 21–29 (2×): 蕾缪安's S3 locks (21–24) and her shells one by one (24–26), skills, crowds of shots and hits
const cost = await frameCost(page, 4000);
await page.close();
t.diagnostic(`boss-m02 1920×1080: ${costLine(cost)}`);
assert.deepEqual(problems, []);
assert.ok(cost.fps > 20, `fps ${cost.fps}`);
assert.ok(cost.cpuMs + cost.renderMs < 16, `JS per frame ${cost.cpuMs} + ${cost.renderMs} ms`);
});
test('drag a hand piece onto a legal board tile; right-click opens detail', async () => {
+81
View File
@@ -77,6 +77,7 @@ function harness(opts = {}) {
const c = createDragController({
hitPiece: (x, y) => pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null,
pickTile: (x, y) => (x < 0 || y < 0 || x >= 2100 ? null : { row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
dropPoint: opts.dropPoint,
isOverCanvas: (cx, cy) => !(opts.domCover && cy > 900),
canPlace: opts.canPlace,
emit: (name, p) => events.push([name, p]),
@@ -217,6 +218,86 @@ describe('drag controller', () => {
assert.equal(h.events.filter((e) => e[0] === 'pieceDrop').length, 0);
});
// user playtest #3 item 7: the drop target is the tile under the dragged ghost's feet (render/app.js dropPoint: the
// pointer for a mouse, a little above the finger for touch) — the hover tile, legality, the ghost and the drop agree
test('dropPoint: target, tileHover, legality and the drop follow the ghost\'s feet; default = the pointer', () => {
const calls = [];
const h = harness({
dropPoint: (e, piece) => { calls.push([e.pointerType, piece.uid]); return e.pointerType === 'touch' ? { x: e.x, y: e.y - 100 } : null; },
canPlace: (p, r) => r !== 11,
});
h.c.setEditable(true);
// mouse: the hook returns nothing → the pointer itself
h.ev('pointerDown', 50, 550);
h.ev('pointerMove', 350, 250);
let mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.hx, mv.hy, mv.target], [350, 250, { area: 'board', row: 10, col: 3 }]);
h.ev('pointerUp', 350, 250);
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 });
// touch: the ghost (and the target) one row above the finger
h.events.length = 0;
h.ev('pointerDown', 50, 550, { pointerType: 'touch' });
h.ev('pointerMove', 450, 350, { pointerType: 'touch' });
mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual([mv.x, mv.y, mv.hx, mv.hy], [450, 350, 450, 250], 'pointer and drop point both reported');
assert.deepEqual(mv.target, { area: 'board', row: 10, col: 4 });
assert.equal(mv.legal, true);
assert.ok(h.events.some((e) => e[0] === 'tileHover' && e[1] && e[1].row === 10 && e[1].col === 4), 'tileHover = the target');
h.ev('pointerMove', 450, 250, { pointerType: 'touch' });
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].legal, false, 'row 11 under the ghost: illegal');
h.ev('pointerUp', 450, 350, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
assert.ok(calls.every(([, uid]) => uid === 1) && calls.some(([t]) => t === 'touch'));
});
// the view may skip its pixel probe on hover picks (a readback per mouse move stalls on the GPU): the controller says
// which calls are hovers — mouse moves without a press, drag moves — and which must be exact: presses and releases
test('hitPiece / dropPoint tell hovers from presses and releases', () => {
const hits = [];
const drops = [];
const pieces = [{ uid: 1, area: 'hand', idx: 0, x: 50, y: 550 }];
const c = createDragController({
hitPiece: (x, y, hover) => { hits.push(hover === true); return pieces.find((p) => Math.abs(p.x - x) < 40 && Math.abs(p.y - y) < 40) || null; },
pickTile: (x, y) => ({ row: 12 - Math.floor(y / 100), col: Math.floor(x / 100) }),
dropPoint: (e, piece, final) => { drops.push(final === true); return null; },
emit: () => {},
});
const ev = (type, x, y) => c[type]({ pointerId: 1, pointerType: 'mouse', button: 0, x, y, clientX: x, clientY: y });
c.setEditable(true);
ev('pointerMove', 50, 550);
assert.deepEqual(hits, [true], 'a mouse move without a press is a hover');
ev('pointerDown', 50, 550);
assert.deepEqual(hits, [true, false], 'a press probes');
ev('pointerMove', 350, 250);
ev('pointerMove', 360, 250);
ev('pointerUp', 360, 250);
assert.ok(drops.length >= 3);
assert.ok(drops.slice(0, -1).every((f) => f === false), 'drag moves are hovers');
assert.equal(drops[drops.length - 1], true, 'the release is final');
});
// review of item 7: on a phone the bench is the lowest canvas row and the shop bar (DOM) sits right under it — a
// touch ghost lifted onto the bench has the finger on the shop bar. DOM cover is judged at the drop point.
test('dropPoint: DOM cover is tested at the ghost\'s feet, not at the finger (touch onto the bench above the shop bar)', () => {
const h = harness({ domCover: true, dropPoint: (e) => (e.pointerType === 'touch' ? { x: e.x, y: e.y - 400 } : null) });
h.c.setEditable(true);
// board unit (10,4) dragged by touch; the finger ends on the covered strip (y > 900), its ghost on bench slot 2
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 950, { pointerType: 'touch' });
const mv = h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1];
assert.deepEqual(mv.target, { area: 'hand', idx: 2 });
assert.equal(mv.legal, true);
h.ev('pointerUp', 250, 950, { pointerType: 'touch' });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'hand', idx: 2 });
// the ghost itself under the DOM: outside (the piece goes back), as for a mouse released on the DOM
h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType: 'touch' });
h.ev('pointerMove', 250, 1350, { pointerType: 'touch' });
assert.equal(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1].target, null);
h.ev('pointerUp', 250, 1350, { pointerType: 'touch' });
assert.equal(h.events.find((e) => e[0] === 'pieceDrop')[1].target.area, 'outside');
});
test('a throwing listener or hit-test never breaks the controller', () => {
const c = createDragController({
hitPiece: () => { throw new Error('boom'); },
+468
View File
@@ -0,0 +1,468 @@
// test/render/fxproj.test.js — battle FX of render/fx.js against a headless fake PIXI (test/render/fakepixi.js):
// * projectile speeds follow the sim (style.js PROJ mirrors server/sim/constants.js; the live table wins)
// * the FX atlas layout (render/textures.js fxFrames): no two frames share a pixel (the light pillar used to run into
// the status-icon row), frame sizes other modules scale by are kept
// * pooled particles: a freed sprite is a zero-size transparent quad (Pixi's ParticleRenderer ignores `visible`) and
// its record is reused — a steady stream of shots stops allocating sprites
// * shots: flight time = distance / sim speed / clock rate, homing on the moving target, one arrival burst, then gone
// * 回环射手 boomerang: out at the sim's speed, back to the thrower's CURRENT position at the return speed, caught there;
// dropped when the thrower is gone
// * 蕾缪安 S3: a lock reticle follows its enemy until the 'bombard' of the shell fired at it; 'bombardShell' flies for
// `t` game s — however long the S3 runs (extra ammo, stuns); blasts named after the shooter happen at their (x, y);
// the S2 aim lock ends with its 'crit'; a lock of a shooter gone quiet times out
// * fx anchoring (_where), quality / load gating of cosmetic particles, melee slash direction, skill burst + aura
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import * as SIM from '../../server/sim/constants.js';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { PROJ, HIT_TINT } from '../../public/js/render/style.js';
let fake, FX, T;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
T = await import('../../public/js/render/textures.js');
});
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
/** A unit view as the FX system reads it. */
function unit(id, x, y, o = {}) {
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {}, ...o };
}
/** A FxSystem on fake layers; `views` resolves fx ids; ts = battle clock rate. */
function makeFx({ quality = 'high', ts = 2, load = 0, views = [] } = {}) {
const P = fake.P;
const ctx = fakeViewCtx(P);
const map = new Map(views.map((v) => [v.id, v]));
const fx = new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
timeScale: () => ts, loadLevel: () => load, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds, dt = DT) => { for (let t = 0; t < seconds - 1e-9; t += dt) fx.update(dt); };
const texName = (fx, tex) => Object.keys(fx.tex).find((k) => fx.tex[k] === tex);
const liveTex = (fx, name) => fx.parts.filter((p) => p.sp.texture === fx.tex[name]);
describe('projectile speeds follow the sim', () => {
test('style.js PROJ mirrors PROJECTILE_SPEEDS for every sim kind (and the boomerang return speed)', () => {
for (const [kind, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) {
assert.ok(PROJ[kind], `render/style.js PROJ has no visual for the sim's projectile kind '${kind}'`);
assert.equal(PROJ[kind].speed, v, `PROJ.${kind}.speed ${PROJ[kind].speed} ≠ sim PROJECTILE_SPEEDS.${kind} ${v}: update the mirror`);
}
if (SIM.BOOMERANG_RETURN_SPEED !== undefined) assert.equal(PROJ.boomerang.back, SIM.BOOMERANG_RETURN_SPEED, 'PROJ.boomerang.back mirrors BOOMERANG_RETURN_SPEED');
});
test('projSpeed: the live sim table first, then the style.js copy; boomerangReturn = the return speed', () => {
FX.setSimProjectileSpeeds(null);
assert.equal(FX.projSpeed('orb'), PROJ.orb.speed);
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
assert.equal(FX.projSpeed('nope'), 12);
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, orb: 99, boomerang: 20 }, 5);
assert.equal(FX.projSpeed('orb'), 99);
assert.equal(FX.projSpeed('boomerang'), 20);
assert.equal(FX.projSpeed('boomerangReturn'), 5);
FX.setSimProjectileSpeeds({ arrow: 1 }); // an older sim table: the rest from the copy
assert.equal(FX.projSpeed('arrow'), 1);
assert.equal(FX.projSpeed('bolt'), PROJ.bolt.speed);
assert.equal(FX.projSpeed('boomerangReturn'), PROJ.boomerang.back);
FX.setSimProjectileSpeeds(SIM.PROJECTILE_SPEEDS, SIM.BOOMERANG_RETURN_SPEED);
for (const [k, v] of Object.entries(SIM.PROJECTILE_SPEEDS)) assert.equal(FX.projSpeed(k), v);
FX.setSimProjectileSpeeds(null);
});
});
describe('FX atlas layout', () => {
test('frames never overlap and stay inside the atlas; shared frame sizes are kept', () => {
const { size: [W, H], frames } = T.fxFrames();
const list = Object.entries(frames);
for (const [name, [x, y, w, h]] of list) assert.ok(x >= 0 && y >= 0 && x + w <= W && y + h <= H, `${name} inside ${W}×${H}`);
const hits = [];
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const [a, [ax, ay, aw, ah]] = list[i], [b, [bx, by, bw, bh]] = list[j];
if (ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah) hits.push(`${a} × ${b}`);
}
}
assert.deepEqual(hits, [], 'overlapping FX atlas frames');
// sizes other modules scale sprites by (units.js glow / chevron, tiles.js soft / ring / dot / smoke, app.js coin)
const size = (n) => frames[n].slice(2).join('×');
for (const [n, s] of [['glow', '128×128'], ['soft', '128×128'], ['ring', '128×128'], ['hex', '128×128'], ['smoke', '128×128'], ['dot', '32×32'], ['chevron', '64×64'], ['coin', '48×48'], ['pillar', '64×256'], ['streak', '128×32'], ['slash', '128×64']]) assert.equal(size(n), s, n);
for (const k of T.STATUS_KEYS) assert.equal(size('st_' + k), '32×32', k);
for (const n of ['tracer', 'shock', 'reticle', 'flare', 'boomerang', 'muzzle']) assert.ok(frames[n], `new frame ${n}`);
});
test('fxAtlas builds one texture per frame on one base texture', () => {
const a = T.fxAtlas();
const { frames } = T.fxFrames();
assert.deepEqual(Object.keys(a.tex).sort(), Object.keys(frames).sort());
for (const t of Object.values(a.tex)) assert.equal(t.baseTexture, a.base);
});
});
describe('particles', () => {
test('a freed particle is a zero-size transparent quad and its record is reused', () => {
const { fx } = makeFx();
const p = fx.particle('glow', 10, 10, { life: 0.05, s0: 1, a0: 1, a1: 1 });
const sp = p.sp;
run(fx, 0.1);
assert.equal(fx.parts.length, 0);
assert.equal(sp.alpha, 0, 'alpha 0 (the particle renderer draws invisible children too)');
assert.deepEqual([sp.scale.x, sp.scale.y], [0, 0], 'zero size: no fill cost');
const n = fx.addPc.children.length;
const q = fx.particle('spark', 5, 5, { life: 0.3 });
assert.equal(q, p, 'the pooled record comes back');
assert.equal(fx.addPc.children.length, n, 'no new sprite');
assert.equal(q.sp.texture, fx.tex.spark);
assert.ok(q.sp.alpha > 0 && q.sp.scale.x > 0);
});
test('a steady stream of shots and hits only ever makes as many sprites as were alive at once', () => {
const a = unit(1, 3, 10), b = unit(2, 8, 11, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
const kinds = ['arrow', 'bolt', 'orb', 'bomb', 'lob', 'drone', 'enemy', 'boomerang', 'none'];
let peakAdd = 0, peakNorm = 0, peakProj = 0;
const sample = () => {
let add = 0;
for (const p of fx.parts) if (p.add) add++;
peakAdd = Math.max(peakAdd, add);
peakNorm = Math.max(peakNorm, fx.parts.length - add);
peakProj = Math.max(peakProj, fx.projs.length);
};
for (let f = 0; f < 60 * 12; f++) {
if (f % 6 === 0) {
const i = f / 6;
fx.attack(a, b, kinds[i % kinds.length]);
fx.damage(b, 120, i % 2 ? 'arts' : 'phys', i % 9 === 8 ? a : null);
}
sample(); // (particles are freed only at the start of an update)
fx.update(1 / 60);
sample();
}
assert.ok(peakProj >= 3 && peakAdd > 20, `busy enough (${peakProj} shots, ${peakAdd} particles at once)`);
assert.equal(fx.addPc.children.length, peakAdd, 'additive particle sprites = the peak alive at once');
assert.equal(fx.normPc.children.length, peakNorm, 'normal-blend particle sprites = the peak alive at once');
assert.equal(fx.projLayer.children.length, 3 * peakProj, 'three sprites per projectile record');
assert.equal(fx.shadowLayer.children.length, peakProj);
assert.ok(fx.parts.length <= fx.maxParticles);
});
});
describe('shots', () => {
test('flight time = distance / sim speed / clock rate; homing on the moving target; one arrival burst', () => {
FX.setSimProjectileSpeeds(null);
const a = unit(1, 3, 10), b = unit(2, 9, 10, { isEnemy: true });
const { fx } = makeFx({ ts: 2, views: [a, b] });
fx.attack(a, b, 'arrow');
assert.equal(fx.projs.length, 1);
const pr = fx.projs[0];
const dist = Math.hypot(b.x - a.x, b.y - a.y);
assert.ok(Math.abs(pr.dur - dist / PROJ.arrow.speed / 2) < 1e-9, `dur ${pr.dur}`);
assert.equal(pr.trail.texture, fx.tex.tracer);
assert.equal(liveTex(fx, 'muzzle').length, 1, 'muzzle flash at the shooter');
b.x = 9.5; // the target moves: the shot follows it
let t = 0;
while (!pr.hit && t < 1) { fx.update(DT); t += DT; }
assert.ok(Math.abs(t - pr.dur) <= DT + 1e-9, `arrived after ${t} (dur ${pr.dur})`);
assert.equal(pr.tx, 9.5);
const flares = liveTex(fx, 'flare').length;
assert.ok(flares >= 1, 'arrival flare');
run(fx, 0.2);
assert.equal(fx.projs.length, 0, 'released after its short fade');
assert.equal(pr.trail.visible || pr.halo.visible || pr.core.visible, false);
});
test('every projectile kind flies and lands; shells cast a shadow and explode', () => {
const a = unit(1, 3, 10), b = unit(2, 7, 11, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
for (const kind of Object.keys(PROJ)) {
fx.clear();
fx.attack(a, b, kind);
assert.equal(fx.projs.length, 1, kind);
const pr = fx.projs[0];
fx.update(DT);
assert.equal(pr.shadow.visible, kind === 'bomb' || kind === 'lob' || kind === 'boomerang', `${kind} shadow`);
if (kind === 'bomb') {
run(fx, pr.dur);
assert.ok(fx.rings.some((r) => r.sp.texture === fx.tex.shock), 'bomb explosion shockwave');
}
run(fx, 3);
assert.equal(fx.projs.length, 0, `${kind} done`);
}
});
test("an arts blastcaster's bomb explodes purple", () => {
const a = unit(1, 3, 10, { info: { defId: 'char_blast' } }), b = unit(2, 6, 10, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
fx.ctx.subProfOf = (id) => (id === 'char_blast' ? 'blastcaster' : null);
fx.attack(a, b, 'bomb');
assert.equal(fx.projs[0].glow, 0xb36bff);
});
test('chain / beam kinds are beams, melee shots are no projectile', () => {
const a = unit(1, 3, 10), b = unit(2, 5, 10, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
fx.attack(a, b, 'chain');
fx.attack(a, b, 'beam');
fx.attack(a, b, 'none');
assert.equal(fx.projs.length, 0);
assert.equal(fx.beamList.length, 2);
assert.equal(fx._slashAt, a.id);
});
});
describe('回环射手 boomerang', () => {
test('out at the boomerang speed, back to where the thrower is now at the return speed, caught there', () => {
FX.setSimProjectileSpeeds({ ...SIM.PROJECTILE_SPEEDS, boomerang: 15 }, 3.75);
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
const { fx } = makeFx({ ts: 2, views: [thrower, target] });
fx.attack(thrower, target, 'boomerang');
const pr = fx.projs[0];
const out = Math.hypot(target.x - pr.bx, target.y - pr.by);
let t = 0;
while (pr.phase === 0 && t < 3) { fx.update(DT); t += DT; }
assert.ok(Math.abs(t - out / (15 * 2)) <= 2 * DT, `out leg ${t.toFixed(3)} s ≈ ${(out / 30).toFixed(3)} s`);
thrower.x = 3; // the thrower moved meanwhile: it flies to where it is now
const back = Math.hypot(thrower.x - target.x, thrower.y - target.y);
let t2 = 0;
while (fx.projs.includes(pr) && t2 < 5) { fx.update(DT); t2 += DT; }
assert.ok(Math.abs(t2 - back / (3.75 * 2)) <= 2 * DT, `back leg ${t2.toFixed(3)} s ≈ ${(back / 7.5).toFixed(3)} s`);
assert.ok(Math.abs(pr.bx - 3) < 1e-9, 'caught at the thrower');
FX.setSimProjectileSpeeds(null);
});
test('it spins, and is gone at once when the thrower leaves the field', () => {
const thrower = unit(1, 2, 10), target = unit(2, 8, 10, { isEnemy: true });
const { fx } = makeFx({ views: [thrower, target] });
fx.attack(thrower, target, 'boomerang');
const pr = fx.projs[0];
fx.update(DT);
const r0 = pr.core.rotation;
fx.update(DT);
assert.notEqual(pr.core.rotation, r0, 'spinning');
assert.equal(pr.core.texture, fx.tex.boomerang);
while (pr.phase === 0) fx.update(DT);
fx.update(DT);
thrower.destroyed = true;
fx.update(DT);
assert.equal(fx.projs.length, 0, 'dropped on the way back');
// a thrower already knocked out at the hit gets nothing back
const t2 = unit(3, 2, 11);
fx.attack(t2, target, 'boomerang');
t2.alive = false;
run(fx, 1);
assert.equal(fx.projs.length, 0);
});
});
describe('蕾缪安 S3: lock, bombardShell, bombard', () => {
const setup = () => {
const lem = unit(1, 2, 10), e1 = unit(21, 8, 10, { isEnemy: true }), e2 = unit(23, 9, 11.5, { isEnemy: true });
return { lem, e1, e2, ...makeFx({ ts: 2, views: [lem, e1, e2] }) };
};
test('a lock follows its enemy until the bombard of the shell fired at it', () => {
const { fx, e1 } = setup();
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
assert.equal(fx.locks.length, 2);
const [L1, L2] = fx.locks;
e1.x = 8.4;
fx.update(DT);
assert.equal(L1.x, 8.4, 'the reticle follows the locked enemy');
assert.equal(L1.ring.texture, fx.tex.reticle);
// the shell fired at e1's spot takes that lock; it flies t game s = t / 2 real s
fx.simFx('bombardShell', 8.5, 10.1, { id: 1, r: 1.5, t: 0.3, i: 0 });
const shell = fx.projs.find((p) => p.kind === 'bombardShell');
assert.ok(shell, 'a shell in the air');
assert.ok(Math.abs(shell.dur - 0.15) < 1e-9);
assert.equal(L1.shell, true);
assert.equal(L2.shell, false);
assert.ok(fx.rings.filter((r) => r.x === 8.5 && r.y === 10.1).length >= 2, 'warning rings on the spot');
run(fx, 0.16);
assert.equal(fx.projs.filter((p) => p.kind === 'bombardShell').length, 0, 'landed');
assert.ok(L1.out < 0, 'the lock waits for the impact itself');
fx.simFx('bombard', 8.5, 10.1, { id: 1, r: 1.5 });
assert.ok(L1.out >= 0, 'released by its bombard');
assert.ok(L2.out < 0, 'the other lock stays');
run(fx, 0.3);
assert.deepEqual(fx.locks, [L2]);
});
test("bombard happens at its (x, y), never on the shooter named in `id`; it is a heavy blast", () => {
const { fx, lem } = setup();
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
assert.ok(fx.rings.length >= 2);
for (const r of fx.rings) assert.deepEqual([r.x, r.y], [8, 10]);
assert.ok(!fx.rings.some((r) => r.x === lem.x), 'nothing on 蕾缪安');
assert.ok(liveTex(fx, 'shard').length > 0, 'debris (heavy)');
});
test('a bombard with no shell (older sims) releases the nearest lock; locks time out; the S2 snipe ends its aim lock', () => {
const { fx } = setup();
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
fx.simFx('bombard', 9, 11.5, { id: 1, r: 1.5 });
assert.deepEqual(fx.locks.map((L) => L.out >= 0), [false, true]);
run(fx, 5 / 2 + 0.3); // LOCK_T game s at 2×, plus the fade
assert.equal(fx.locks.length, 0, 'timed out');
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
fx.simFx('crit', 8, 10, { id: 23, src: 1 }); // another enemy: stays
assert.ok(fx.locks[0].out < 0);
fx.simFx('crit', 8, 10, { id: 21, src: 1 });
assert.ok(fx.locks[0].out >= 0, 'the snipe on the locked enemy ends the lock');
});
test('the shooter knocked out: locks still waiting for a shell go, the one whose shell is in the air stays', () => {
const { fx, lem } = setup();
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
fx.simFx('lock', 9, 11.5, { id: 23, src: 1 });
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
const [L1, L2] = fx.locks;
lem.alive = false;
fx.update(DT);
assert.ok(L1.out < 0, 'its shell still lands');
assert.ok(L2.out >= 0, 'no shell will come');
fx.simFx('bombard', 8, 10, { id: 1, r: 1.5 });
assert.ok(L1.out >= 0);
});
test('a long S3 (extra ammo, a stun mid-lock): every reticle stays until the bombard of its own shell', () => {
// 拉特兰 ammo grants / reloads give S3 more than its 5 shots: 20 locks 0.5 game s apart (a 3 game s stun in the
// middle), then one shell every 0.3 game s landing 0.3 game s later — the first lock waits ≈ 16 game s for its shell
const enemies = Array.from({ length: 4 }, (_, k) => unit(30 + k, 7 + k, 9 + (k % 2), { isEnemy: true }));
const lem = unit(1, 2, 10);
const { fx } = makeFx({ ts: 2, views: [lem, ...enemies] });
const g = (s) => s / 2; // game s → real s at 2×
const locks = [];
for (let k = 0; k < 20; k++) {
const e = enemies[k % 4];
fx.simFx('lock', e.x, e.y, { id: e.id, src: 1 });
locks.push(fx.locks[fx.locks.length - 1]);
run(fx, g(k === 9 ? 3.5 : 0.5));
}
assert.equal(new Set(locks).size, 20, 'no reticle recycled for another lock');
for (let k = 0; k < 20; k++) {
const e = enemies[k % 4];
fx.simFx('bombardShell', e.x + 0.1, e.y, { id: 1, r: 1.5, t: 0.3, i: k });
run(fx, g(0.3));
const live = fx.locks.filter((L) => L.out < 0).length;
assert.equal(live, 20 - k, `shell ${k}: every lock whose bombard is still to come is up`);
fx.simFx('bombard', e.x + 0.1, e.y, { id: 1, r: 1.5 });
}
run(fx, 0.3);
assert.equal(fx.locks.length, 0);
});
test('a lock sticks to the enemy named in `id` even when its view is a little off the event spot', () => {
const { fx, e1 } = setup();
fx.simFx('lock', 9, 10, { id: 21, src: 1 }); // e1 drawn at (8, 10): a fast walker between snapshots
assert.equal(fx.locks[0].view, e1);
e1.x = 7.5;
fx.update(DT);
assert.equal(fx.locks[0].x, 7.5);
});
test('a lock whose shooter goes quiet still times out (S2 aim at an enemy that died)', () => {
const { fx } = setup();
fx.simFx('lock', 8, 10, { id: 21, src: 1 });
run(fx, 5 / 2 - 0.05);
assert.equal(fx.locks[0].out, -1);
run(fx, 0.35);
assert.equal(fx.locks.length, 0);
});
test('a long shell flight climbs out of the shooter first; a short one only falls', () => {
const { fx, lem } = setup();
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 1.2 });
fx.simFx('bombardShell', 9, 11.5, { id: 1, r: 1.5, t: 0.3 });
const [long, short] = fx.projs;
assert.ok(long.rise > 0 && short.rise === 0);
lem.x = 2.5;
fx.update(DT);
assert.equal(long.x0, 2.5, 'rising from where she stands');
run(fx, 1);
assert.equal(fx.projs.length, 0);
});
});
describe('fx placement, quality, melee, skill', () => {
test('_where: on the unit when the fx happens there, else at the event position', () => {
const caster = unit(1, 2, 10);
const { fx } = makeFx({ views: [caster] });
assert.equal(fx._where(2.2, 10.1, { id: 1 }).v, caster, 'on the unit');
assert.equal(fx._where(NaN, NaN, { id: 1 }).v, caster, 'no position: the unit');
const far = fx._where(5, 10, { id: 1 });
assert.deepEqual([far.x, far.y, far.v], [5, 10, null], 'an aoe ahead of its caster');
fx.simFx('aoe', 6, 11, { id: 1, r: 1 });
assert.ok(fx.rings.length && fx.rings.every((r) => r.x === 6 && r.y === 11), 'drawn where it happens');
});
test("quality 'low' and a heavy load skip the cosmetic particles, not the shots", () => {
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
for (const [opts, cosmetic] of [[{ quality: 'high' }, true], [{ quality: 'low' }, false], [{ quality: 'high', load: 2 }, false]]) {
const { fx } = makeFx({ ...opts, views: [a, b] });
fx.attack(a, b, 'bolt');
fx.attack(a, b, 'arrow');
run(fx, 0.05);
assert.equal(fx.projs.length, 2);
const extras = liveTex(fx, 'dot').length + liveTex(fx, 'muzzle').length;
assert.equal(extras > 0, cosmetic, JSON.stringify(opts));
}
});
test('a melee blow draws a slash in the hit colour, swept along the blow', () => {
const a = unit(1, 4, 10), b = unit(2, 5, 10, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
fx.attack(a, b, 'none');
fx.damage(b, 300, 'arts', a);
const slashes = liveTex(fx, 'slash');
assert.equal(slashes.length, 2);
assert.equal(slashes[0].sp.tint, HIT_TINT.arts);
const pa = cam.project(a.x, a.y, 0.54, {}), pb = cam.project(b.x, b.y, 0.54, {});
const blow = Math.atan2(pb.y - pa.y, pb.x - pa.x) + Math.PI / 2;
const d = Math.atan2(Math.sin(slashes[0].sp.rotation - blow), Math.cos(slashes[0].sp.rotation - blow));
assert.ok(Math.abs(d) <= 0.46, `rotation ${slashes[0].sp.rotation} vs blow ${blow}`);
assert.equal(fx._slashAt, null);
fx.damage(b, 300, 'phys', a); // a second hit without a new blow: no slash
assert.equal(liveTex(fx, 'slash').length, 2);
});
test('skill activation bursts (pillars, flare, shockwave + hex) and keeps an aura until it ends', () => {
const v = unit(1, 4, 10);
const { fx } = makeFx({ views: [v] });
fx.skill(v, true);
assert.equal(liveTex(fx, 'pillar').length, 2);
assert.equal(liveTex(fx, 'flare').length, 1);
assert.deepEqual(fx.rings.map((r) => texName(fx, r.sp.texture)).sort(), ['hex', 'shock']);
assert.equal(fx.auras.size, 1);
run(fx, 1);
const a = fx.auras.get(1);
assert.ok(a.sp.alpha > 0.9 && a.hex.alpha > 0.5, 'aura shown');
fx.skill(v, false);
run(fx, 0.6);
assert.equal(fx.auras.size, 0, 'faded out');
});
test('clear() drops shots, locks, auras and particles', () => {
const a = unit(1, 3, 10), b = unit(2, 8, 10, { isEnemy: true });
const { fx } = makeFx({ views: [a, b] });
fx.attack(a, b, 'boomerang');
fx.simFx('lock', 8, 10, { id: 2, src: 1 });
fx.simFx('bombardShell', 8, 10, { id: 1, r: 1.5, t: 0.3 });
fx.skill(a, true);
fx.update(DT);
fx.clear();
assert.deepEqual([fx.projs.length, fx.locks.length, fx.auras.size, fx.parts.length], [0, 0, 0, 0]);
fx.update(DT);
fx.destroy();
});
});
+361
View File
@@ -0,0 +1,361 @@
// test/render/models.browser.test.js — user playtest #3 items 1 and 7 in headless Chrome, through the in-match mock
// (public/dev/game-mock.html: the real game screen, a mock server behind net.request):
// 1 operator models never go missing: after a battle that outlasts the Spine LRU grace, the battle → prep switch
// (every battle view destroyed, the prep views built in the same task) used to unload the bench models and hand
// the doomed skeletons straight back (PIXI.Assets serves an unloading asset from its loader cache): invisible
// chibis. Also a burst of rapid drags / swaps / direction-wheel previews and commits / cancels / re-drags. After
// each: every prep piece has a view, visible, on its tile, drawing a live Spine model (textures alive) — and the
// canvas pixels over it change when it is hidden (it really draws something).
// 7 picking: with an operator directly behind another (same column), presses on its head / torso / legs, on its
// feet where they show and on its tile beside the front one's head select IT; the front one's face selects the
// front one — in prep and in battle; equipment dropped on the body of the one behind equips it; a dragged unit's
// ghost stands on the pointer and the drop (direction wheel) is that tile. Every press on a drawn unit pixel (the
// whole prep board and a battle) picks the front-most unit drawn there (render/pick.js pixel probe).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Page-side helpers (installed once per page): the prep pieces, their views, the invariants of item 1. */
function installHelpers() {
const G = { HAND_ROW: 7, TEMP_ROW: 8, TEMP_C0: 4 };
const raw = () => globalThis.__SP_VIEW__.raw;
const priv = () => globalThis.__MOCK__.store.get().match.private;
const texOk = (t) => !!t && !t.destroyed && !!t.baseTexture && !t.baseTexture.destroyed && t.baseTexture.valid !== false;
window.__t = {
pieces() {
const p = priv(), out = [];
(p.board || []).forEach((x) => x && out.push({ uid: x.uid, kind: x.kind, row: x.row, col: x.col }));
(p.hand || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.HAND_ROW, col: i }));
(p.temp || []).forEach((x, i) => x && out.push({ uid: x.uid, kind: x.kind, row: G.TEMP_ROW, col: G.TEMP_C0 + i }));
return out;
},
/** Client point `up` tiles above the feet of a view (+ `dx` tiles aside); views keyed 'p:uid' (prep) or unit id. */
at(key, up, dx = 0) {
const R = raw(), v = R.debug.views.get(key), cv = R.debug.app.view.getBoundingClientRect();
return { x: cv.left + v.screen.x + dx * v.screen.s, y: cv.top + v.screen.y - up * v.screen.s };
},
/** Item 1 invariants; returns the problems found. */
check() {
const R = raw(), problems = [];
for (const p of this.pieces()) {
const v = R.debug.views.get('p:' + p.uid), tag = `${p.uid}@${p.row},${p.col}`;
if (!v || v.destroyed) { problems.push(`${tag}: no view`); continue; }
if (!v.root.visible || !(v.root.alpha > 0.9)) problems.push(`${tag}: hidden (${v.root.visible}, ${v.root.alpha})`);
if (Math.abs(v.x - p.col) > 0.05 || Math.abs(v.y - p.row) > 0.05) problems.push(`${tag}: parked at ${v.x.toFixed(2)},${v.y.toFixed(2)}`);
if (p.kind === 'item') continue;
if (!v.actor || !v.spineReady) { problems.push(`${tag}: no Spine model`); continue; }
let dead = 0, n = 0;
for (const slot of v.actor.spine.skeleton.slots) { const s = slot.currentSprite || slot.currentMesh; if (!s || !s.texture) continue; n++; if (!texOk(s.texture)) dead++; }
if (dead) problems.push(`${tag}: ${dead}/${n} textures destroyed`);
if (v.imp) { if (!texOk(v.imp.sprite.texture)) problems.push(`${tag}: impostor texture destroyed`); }
else if (v.actor.spine.parent !== v.body || !v.actor.spine.visible || !(v.actor.spine.alpha > 0.9)) problems.push(`${tag}: model not shown`);
}
return problems;
},
/** Share of the pixels over each unit's body that change when the unit is hidden (≈ 0: nothing drawn). */
drawn() {
const R = raw(), app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution, H = rd.view.height, out = {};
for (const p of this.pieces()) {
if (p.kind === 'item') continue;
const v = R.debug.views.get('p:' + p.uid);
if (!v) continue;
const b = v.bounds();
const x0 = Math.max(0, Math.floor(b.x * res)), y0 = Math.max(0, Math.floor(b.y * res));
const w = Math.max(1, Math.min(Math.floor(b.width * res), rd.view.width - x0)), h = Math.max(1, Math.min(Math.floor(b.height * res), H - y0));
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(x0, H - y0 - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
const a = read();
v.root.visible = false;
const c = read();
v.root.visible = true;
let diff = 0;
for (let i = 0; i < a.length; i += 4) if (Math.abs(a[i] - c[i]) + Math.abs(a[i + 1] - c[i + 1]) + Math.abs(a[i + 2] - c[i + 2]) + Math.abs(a[i + 3] - c[i + 3]) > 40) diff++;
out[p.uid] = diff / (w * h);
}
return out;
},
};
}
describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, w = 1920, h = 1080) {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setViewport({ width: w, height: h });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
await page.evaluate(installHelpers);
await sleep(2000); // Spine models
return { page, problems };
}
const setPhase = (page, ph) => page.evaluate(async (p) => { const { PHASE } = await import('/shared/constants.js'); globalThis.__MOCK__.setPhase(PHASE[p]); }, ph);
const uidAt = (page, row, col) => page.evaluate((r, c) => globalThis.__MOCK__.S().priv.board.find((p) => p.row === r && p.col === c)?.uid ?? null, row, col);
const assertAllDrawn = async (page, label) => {
assert.deepEqual(await page.evaluate(() => window.__t.check()), [], `${label}: every prep model alive and in place`);
const drawn = await page.evaluate(() => window.__t.drawn());
for (const [uid, frac] of Object.entries(drawn)) assert.ok(frac > 0.05, `${label}: unit ${uid} draws pixels (${frac.toFixed(3)})`);
};
test('1: the prep models come back alive after a battle that outlasts the Spine LRU grace', async () => {
const { page, problems } = await open('phase=PREP');
await assertAllDrawn(page, 'first prep');
// a short grace stands in for a long battle (default 15 s)
await page.evaluate(async () => { const { assets } = await import('/js/assets.js'); assets.spine.cache.idleGrace = 400; });
for (let round = 0; round < 2; round++) {
await setPhase(page, 'COMBAT');
await sleep(2000);
await setPhase(page, 'PREP');
await sleep(2500);
await assertAllDrawn(page, `prep after battle ${round + 1}`);
}
const st = await page.evaluate(async () => (await import('/js/assets.js')).assets.spine.stats());
assert.equal(st.unloading, 0);
await page.screenshot({ path: path.join(OUT, 'models-after-battles.png') });
await page.close();
assert.deepEqual(problems, []);
});
test('1: rapid drags, swaps, wheel previews (all four directions) with commit / Esc / ✕ and re-drags keep every model', async () => {
const { page, problems } = await open('phase=PREP', 1600, 900);
let seed = 7;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
const tiles = await page.evaluate(() => { const st = globalThis.__MOCK__.S().stage; return [...st.deployTiles.normal.melee, ...st.deployTiles.normal.rangedOnly]; });
const drag = async (from, to) => {
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 6; i++) await page.mouse.move(from.x + ((to.x - from.x) * i) / 6, from.y + ((to.y - from.y) * i) / 6);
await page.mouse.up();
};
const wheel = async (how) => {
const g = await page.evaluate(() => { const el = document.querySelector('.fwheel__dia'); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, h: r.height / 2 }; });
if (!g) return;
if (how === 'esc') { await page.keyboard.press('Escape'); return; }
if (how === 'x') { await page.click('.fwheel__cancel').catch(() => {}); return; }
await page.mouse.move(g.x, g.y); await page.mouse.down();
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) await page.mouse.move(g.x + dx * g.h * 0.6, g.y + dy * g.h * 0.6, { steps: 3 });
if (how === 'up') await page.mouse.move(g.x, g.y - g.h * 0.6, { steps: 2 });
await page.mouse.up();
};
for (let burst = 0; burst < 2; burst++) {
for (let k = 0; k < 16; k++) {
const units = (await page.evaluate(() => window.__t.pieces())).filter((p) => p.kind !== 'item');
const p = units[Math.floor(rnd() * units.length)];
const [row, col] = rnd() < 0.8 ? tiles[Math.floor(rnd() * tiles.length)] : [7, Math.floor(rnd() * 10)];
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), p.uid);
const to = await page.evaluate((r, c) => globalThis.__SP_VIEW__.raw.tileScreen(r, c), row, col);
await drag(from, to);
await sleep(40 + rnd() * 80);
await wheel(['up', 'up', 'left', 'esc', 'x'][Math.floor(rnd() * 5)]);
await sleep(rnd() < 0.5 ? 20 : 300); // re-drag before the mock server answered, or after
}
await page.keyboard.press('Escape');
await sleep(1800);
await assertAllDrawn(page, `burst ${burst + 1}`);
}
await page.close();
assert.deepEqual(problems, []);
});
test('7: picking an operator standing directly behind another — prep and battle; equipment on the one behind', async () => {
const { page, problems } = await open('phase=PREP');
// col 4: row 9 (ground) · rows 10–12 (high ground) — a ranged operator behind a melee one, and two on the same height
const [a9, b10, c11] = [await uidAt(page, 9, 4), await uidAt(page, 10, 4), await uidAt(page, 11, 4)];
assert.ok(a9 && b10 && c11, 'mock premise: three operators in one column');
const clicks = async (keyOf, idOf, cases) => {
await page.evaluate(() => { window.__picked = []; window.__offPick?.(); window.__offPick = globalThis.__SP_VIEW__.raw.on('pieceClick', (e) => window.__picked.push(e.uid)); });
for (const [label, key, up, dx, want] of cases) {
const pt = await page.evaluate((k, u, d) => window.__t.at(k, u, d), keyOf(key), up, dx);
await page.mouse.click(pt.x, pt.y);
await sleep(120);
const got = await page.evaluate(() => window.__picked.splice(0));
assert.deepEqual(got.slice(-1), [idOf(want)], `${label}`);
await page.keyboard.press('Escape');
await sleep(60);
}
};
const cases = [
// [label, whose feet, tiles up, tiles aside, expected]
['raised B: face', b10, 0.86, 0, b10], ['raised B: torso', b10, 0.45, 0, b10], ['raised B: legs', b10, 0.22, 0, b10],
// B's feet where they show (straight under B's centre, A's hair crown is drawn over them: that pixel is A's)
['raised B: feet', b10, 0.05, -0.12, b10],
['raised B: its tile beside A\'s head', b10, 0, 0.42, b10],
['A (in front): face', a9, 0.86, 0, a9], ['A: torso', a9, 0.45, 0, a9], ['A: feet', a9, 0.05, 0, a9],
// (B's head and staff are drawn over the middle of C's lower torso and legs: those pixels are B's)
['same height C behind B: face', c11, 0.86, 0, c11], ['C: torso', c11, 0.62, 0, c11], ['C: legs beside B\'s head', c11, 0.4, -0.2, c11],
['C: its tile beside B\'s head', c11, 0, -0.42, c11], ['B in front of C: face', b10, 0.86, 0, b10],
];
await clicks((u) => 'p:' + u, (u) => u, cases);
// equipment: an item dropped on the torso of the one behind (C) equips C; one on A's face equips A
const items = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.filter((x) => x && x.kind === 'item').map((x) => x.uid));
for (const [target, up] of [[c11, 0.62], [a9, 0.86]]) {
const it = items.shift();
const from = await page.evaluate((u) => { const r = globalThis.__SP_VIEW__.pieceScreenRect(u); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }, it);
const to = await page.evaluate((u, k) => window.__t.at('p:' + u, k), target, up);
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
await sleep(120);
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.dragHit?.over ?? null), target, 'the plate is over the target (pieceDragOver)');
await page.mouse.up();
await sleep(600);
const eq = await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.find((p) => p.uid === u).items.map((x) => x.uid), target);
assert.ok(eq.includes(it), `item ${it} equipped on ${target} (got ${eq})`);
}
await page.screenshot({ path: path.join(OUT, 'models-pick-prep.png') });
// battle: the same presses on the battle views (ids → piece uids)
await setPhase(page, 'COMBAT');
await sleep(2500);
// …at a moment of the battle when the units stand at rest: a press takes whatever is DRAWN under it (pixel probe),
// and the mock battle keeps every operator attacking (lunges, attack clips) and in random skill poses, which move
// the drawn face / torso / legs off the aimed points (then the unit in front or behind rightly takes the press —
// the pixel-truth test below covers moving poses). So the mock battle is stopped with one last snapshot that clears
// the skill flags, and the presses wait until every ally is back in its idle pose.
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const S = globalThis.__MOCK__.S();
const last = await new Promise((resolve) => { const off = net.on('b.snap', (m) => { off(); resolve(m); }); });
clearInterval(S.battle.timer);
net._emit('b.snap', { ...last, gt: last.gt + 0.2, units: last.units.map((u) => { const c = u.slice(); c[7] = 0; return c; }) });
});
await page.waitForFunction(() => [...globalThis.__SP_VIEW__.raw.debug.views.values()]
.filter((v) => v.info?.side === 'ally' && v.alive && v.actor)
.every((v) => v.actor.mode === 'base' && !v.actor.skillOn && !(v.lunge > 0)), { timeout: 15000, polling: 100 });
await sleep(300);
const ids = await page.evaluate(() => { const out = {}; for (const [id, v] of globalThis.__SP_VIEW__.raw.debug.views) if (v.info?.uid != null) out[v.info.uid] = id; return out; });
await clicks((u) => ids[u], (u) => u, cases.filter(([label]) => !label.includes('tile')));
await page.screenshot({ path: path.join(OUT, 'models-pick-battle.png') });
await page.close();
assert.deepEqual(problems, []);
});
// review of item 7: the pick against what is really drawn. Every unit is rendered alone (its pixels = where its model
// is opaque); the truth at a pixel is the front-most unit drawn there. Sampled over the whole prep board and a
// battle: the shape rule alone got 7 % (battle 18 %) of these presses wrong, mostly giving the front unit's hair /
// arms to the unit behind; the pixel probe settles every overlap.
test('7: picks agree with the rendered pixels (prep and battle)', async () => {
const { page, problems } = await open('phase=PREP');
const measure = (phase) => page.evaluate((ph) => {
const R = globalThis.__SP_VIEW__.raw, app = R.debug.app, rd = app.renderer, gl = rd.gl, res = rd.resolution;
const keyOf = new Map([...R.debug.views].map(([k, v]) => [v, k]));
const views = [...R.debug.views].filter(([k, v]) => v.pickShape && v.info && v.info.kind !== 'item' && v.info.kind !== 'device'
&& v.alive && v.spineReady && !v.culled && (ph === 'PREP' ? k.startsWith('p:') : true));
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [, v] of views) { const s = v.screen.s; x0 = Math.min(x0, v.screen.x - s); x1 = Math.max(x1, v.screen.x + s); y0 = Math.min(y0, v.screen.y - 2 * s); y1 = Math.max(y1, v.screen.y + 0.3 * s); }
x0 = Math.max(0, Math.floor(x0)); y0 = Math.max(0, Math.floor(y0));
x1 = Math.min(rd.view.width / res - 1, Math.ceil(x1)); y1 = Math.min(rd.view.height / res - 1, Math.ceil(y1));
const w = Math.floor((x1 - x0) * res), h = Math.floor((y1 - y0) * res), H = rd.view.height;
const all = [...R.debug.views.values()];
const saved = all.map((v) => [v, v.root?.visible, v.hud?.visible, v.shadow?.visible]);
for (const v of all) { if (v.hud) v.hud.visible = false; if (v.shadow) v.shadow.visible = false; if (v.root) v.root.visible = false; }
const read = () => { R.debug.ctx.impostors?.flush?.(); rd.render(app.stage); const px = new Uint8Array(w * h * 4); gl.readPixels(Math.floor(x0 * res), H - Math.floor(y0 * res) - h, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); return px; };
const bg = read();
const masks = [];
for (const [k, v] of views) {
v.root.visible = true; const a = read(); v.root.visible = false;
const m = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) { const j = i * 4; if (a[j + 3] > 40 && Math.abs(a[j] - bg[j]) + Math.abs(a[j + 1] - bg[j + 1]) + Math.abs(a[j + 2] - bg[j + 2]) + Math.abs(a[j + 3] - bg[j + 3]) > 40) m[i] = 1; }
masks.push({ k, m, z: v.root.zIndex });
}
for (const [v, a, b, c] of saved) { if (v.root) v.root.visible = a; if (v.hud) v.hud.visible = b; if (v.shadow) v.shadow.visible = c; }
const pick = (x, y) => { if (ph === 'PREP') { const p = R.debug.pick.pieceAt(x, y); return p ? 'p:' + p.uid : null; } const v = R.debug.pick.battleUnitAt(x, y); return v ? keyOf.get(v) : null; };
let n = 0, ok = 0, wrong = 0;
for (let py = 0; py < h; py += 4) for (let px = 0; px < w; px += 4) {
const i = (h - 1 - py) * w + px;
let truth = null, tz = -Infinity;
for (const mk of masks) if (mk.m[i] && mk.z > tz) { tz = mk.z; truth = mk.k; }
if (!truth) continue;
const got = pick(x0 + px / res, y0 + py / res);
n++;
if (got === truth) ok++; else if (got) wrong++;
}
return { units: views.length, n, ok: ok / n, wrong: wrong / n };
}, phase);
const prep = await measure('PREP');
assert.ok(prep.units >= 10 && prep.n > 3000, `premise: a full prep board (${JSON.stringify(prep)})`);
assert.ok(prep.ok > 0.95 && prep.wrong < 0.015, `prep: presses on a drawn unit pick it (${JSON.stringify(prep)})`);
await setPhase(page, 'COMBAT');
await sleep(3000);
const battle = await measure('COMBAT');
assert.ok(battle.units >= 10 && battle.n > 3000, `premise: a battle (${JSON.stringify(battle)})`);
assert.ok(battle.ok > 0.93 && battle.wrong < 0.03, `battle: presses on a drawn unit pick it (${JSON.stringify(battle)})`);
await page.close();
assert.deepEqual(problems, []);
});
test('7: a dragged unit stands on the pointer; the drop and its direction wheel are that tile', async () => {
const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => globalThis.__MOCK__.S().priv.hand.find((x) => x && x.kind === 'chess').uid);
const from = await page.evaluate((u) => window.__t.at('p:' + u, 0.5), hand);
const to = await page.evaluate(() => globalThis.__SP_VIEW__.raw.tileScreen(9, 4)); // an occupied melee tile: a swap
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 10; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10); await sleep(16); }
await sleep(150);
const ghost = await page.evaluate((u) => { const v = globalThis.__SP_VIEW__.raw.debug.views.get('p:' + u); return { x: v.x, y: v.y, lift: v.lift }; }, hand);
assert.ok(Math.abs(ghost.x - 4) < 0.06 && Math.abs(ghost.y - 9) < 0.06 && ghost.lift > 0, `the ghost stands on the pointer's tile (${ghost.x}, ${ghost.y})`);
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
await page.mouse.up();
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
assert.ok(Math.hypot(w.x - to.x, w.y - to.y) < 2, 'the wheel opens on that tile');
await page.keyboard.press('Escape');
await page.close();
assert.deepEqual(problems, []);
});
// review of item 7: a touch drag holds its ghost (and drop point) 0.6 tile above the finger. On a phone the bench is
// the lowest canvas row with the shop bar right under it, so putting a unit back on the bench has the finger on the
// shop bar: the drop is judged where the ghost is, not under the finger (it used to send the piece back).
test('7: touch — a board unit dragged back onto a bench slot above the shop bar lands there (phone)', async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true });
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=engine&phase=PREP`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__?.raw?.debug, { timeout: 30000 });
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.hand[5] = null; }));
await sleep(2000);
const g = await page.evaluate(() => {
const V = globalThis.__SP_VIEW__.raw, cv = V.debug.app.view.getBoundingClientRect();
const u = globalThis.__MOCK__.S().priv.board[0], v = V.debug.views.get('p:' + u.uid), t = V.tileScreen(7, 5);
const finger = { x: t.x, y: t.y + 0.6 * t.s };
const el = document.elementFromPoint(finger.x, finger.y);
return { uid: u.uid, from: { x: cv.left + v.screen.x, y: cv.top + v.screen.y - 0.5 * v.screen.s }, finger, onDom: !!el && el !== V.debug.app.view };
});
assert.ok(g.onDom, 'premise: the finger is on the DOM (shop bar) below the bench slot');
await page.touchscreen.touchStart(g.from.x, g.from.y);
for (let i = 1; i <= 12; i++) { await page.touchscreen.touchMove(g.from.x + ((g.finger.x - g.from.x) * i) / 12, g.from.y + ((g.finger.y - g.from.y) * i) / 12); await sleep(16); }
await sleep(100);
await page.touchscreen.touchEnd();
await sleep(800);
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), g.uid, 'the unit is on bench slot 5');
await page.close();
assert.deepEqual(problems, []);
});
});
+206
View File
@@ -0,0 +1,206 @@
// test/render/pick.test.js — render/pick.js, the one picking rule of every "which unit is under the pointer" path
// (user playtest #3 item 7: "clicking an operator often selects the one a row below it; equipment too"), checked on the
// geometry of the official cameras (render/projection.js presetCamera: prep / battle at 1920×1080 and 1280×720): a unit
// B standing directly behind a unit A (same column, adjacent rows; B on the same height or on a raised tile).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera } from '../../public/js/render/projection.js';
import { pickBody, hitBody, coreDistance, bodyBounds, bodyParts, BODY_H, probeCandidates, PROBE_MARGIN } from '../../public/js/render/pick.js';
import { unitDepthKey } from '../../public/js/render/units.js';
const COL = 4;
/** A / B bodies on a camera: A at row 9 (height zA), B at row 10 (height zB), model heights hA / hB. */
function pair(kind, W, H, { zA = 0, zB = 0, hA = BODY_H, hB = BODY_H } = {}) {
const cam = presetCamera(kind, { width: W, height: H });
const body = (key, row, z, h) => { const p = cam.project(COL, row, z); return { key, x: p.x, y: p.y, s: p.s, h, flip: 1, depth: unitDepthKey(cam, COL, row), tile: { row, col: COL } }; };
const A = body('A', 9, zA, hA), B = body('B', 10, zB, hB);
const heights = new Map([[`9,${COL}`, zA], [`10,${COL}`, zB]]);
// the tile under a screen point, raised tops first (render/projection.js pickTile semantics)
const ground = (x, y) => {
for (const hz of [...new Set([zA, zB, 0])].sort((a, b) => b - a)) {
const g = cam.unproject(x, y, hz);
if (!g) continue;
const t = { row: Math.round(g.y), col: Math.round(g.x) };
if (hz === 0 || Math.abs((heights.get(`${t.row},${t.col}`) || 0) - hz) < 1e-6) return t;
}
return null;
};
/** Who a press `up` tiles above the feet of `u` (and `dx` tiles to the side) picks. */
const at = (u, up, dx = 0, bodies = [A, B]) => { const x = u.x + dx * u.s, y = u.y - up * u.s; return pickBody(bodies, x, y, ground(x, y))?.key ?? null; };
return { cam, A, B, at, rowGap: (A.y - B.y) / A.s };
}
const CAMS = [['prep', 1920, 1080], ['prep', 1280, 720], ['normal', 1920, 1080], ['normal', 1280, 720]];
describe('pickBody on the official cameras: B directly behind A', () => {
for (const [kind, W, H] of CAMS) {
test(`${kind} ${W}×${H}, same height: B's head / face / torso / legs / tile beside A are B's; A's face, torso and feet are A's`, () => {
const { at, A, B, rowGap } = pair(kind, W, H);
assert.ok(rowGap > 0.8 && rowGap < 0.95, `rows ${rowGap.toFixed(2)} of a tile apart on screen (test premise)`);
assert.ok(BODY_H > rowGap, 'a chibi is taller than a row: A covers B\'s feet (test premise)');
for (const up of [1.2, 0.86, 0.6, 0.45, 0.3]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
for (const dx of [-0.4, 0.4]) assert.equal(at(B, 0, dx), 'B', `B's tile beside A's head (${dx})`);
for (const up of [0.86, 0.6, 0.3, 0.05, -0.1]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
// the feet of B are drawn behind A's face: that press is A's (nothing of B shows there)
assert.equal(at(B, 0), 'A');
// A's hair crown over B's legs / tile goes to B; with nobody behind it is A's
assert.equal(at(A, 1.2), 'B');
assert.equal(at(A, 1.2, 0, [A]), 'A');
assert.equal(at(A, 0.86, 0, [A]), 'A');
});
test(`${kind} ${W}×${H}, B on a raised tile (ranged high ground behind a melee lane): B's feet are B's too`, () => {
const { at, B, A } = pair(kind, W, H, { zB: 0.42 });
for (const up of [1.2, 0.86, 0.45, 0.2, 0]) assert.equal(at(B, up), 'B', `B ${up} tile above its feet`);
for (const up of [0.86, 0.45, 0.05]) assert.equal(at(A, up), 'A', `A ${up} tile above its feet`);
});
}
test('the old rule would have picked A for most of B\'s lower half (the playtest report)', () => {
// the pre-fix hit box: 0.7 tile wide, 1.18 tiles above the feet + 0.1 below, front-most wins
const { A, B } = pair('prep', 1920, 1080);
const box = (u, x, y) => Math.abs(x - u.x) <= 0.35 * u.s && y >= u.y - 1.18 * u.s && y <= u.y + 0.1 * u.s;
const old = (x, y) => (box(A, x, y) ? 'A' : box(B, x, y) ? 'B' : null);
let wrong = 0;
for (const up of [0.3, 0.2, 0.1, 0]) for (const dx of [-0.3, 0, 0.3]) if (old(B.x + dx * B.s, B.y - up * B.s) === 'A') wrong++;
assert.ok(wrong >= 6, `old rule: ${wrong}/12 presses on B's lower body / feet went to A`);
});
});
describe('pickBody rule details', () => {
const cam = presetCamera('prep', { width: 1920, height: 1080 });
const at = (row, col, extra = {}) => { const p = cam.project(col, row, 0); return { x: p.x, y: p.y, s: p.s, h: BODY_H, depth: unitDepthKey(cam, col, row), tile: { row, col }, ...extra }; };
test('off every body, the unit standing on the pressed tile is picked; an empty tile picks nothing', () => {
const U = { key: 'U', ...at(10, 6) };
const tile = cam.project(6, 10, 0);
// the tile's corner, well away from the body
const cx = tile.x + 0.45 * tile.s, cy = tile.y + 0.3 * tile.s;
assert.equal(hitBody(U, cx, cy), 0, 'premise: not on the body');
assert.equal(pickBody([U], cx, cy, { row: 10, col: 6 }), U);
assert.equal(pickBody([U], cx, cy, { row: 10, col: 7 }), null);
assert.equal(pickBody([U], cx, cy, null), null);
});
test('side by side (same row): each unit keeps its own body, weapon edges go by the tile', () => {
const L = { key: 'L', ...at(10, 5) }, R = { key: 'R', ...at(10, 6) };
for (const u of [L, R]) for (const up of [0.9, 0.5, 0.15]) assert.equal(pickBody([L, R], u.x, u.y - up * u.s, u.tile), u);
const mid = (L.x + R.x) / 2;
assert.equal(pickBody([L, R], mid - 2, L.y - 0.3 * L.s, { row: 10, col: 5 }), L);
assert.equal(pickBody([L, R], mid + 2, L.y - 0.3 * L.s, { row: 10, col: 6 }), R);
});
test('a moving unit (no tile, e.g. an enemy walking in front) is picked by its body only, front-most on cores', () => {
const op = { key: 'op', ...at(10, 5) };
const foe = { key: 'foe', ...at(9, 5), tile: null, w: 1.1, h: 1.3 };
assert.equal(pickBody([op, foe], foe.x, foe.y - 0.5 * foe.s, { row: 9, col: 5 }), foe, 'its torso');
assert.equal(pickBody([op, foe], op.x, op.y - 0.6 * op.s, { row: 10, col: 5 }), op, 'the operator above it');
// off its body on the tile it walks over: nothing stands there
assert.equal(pickBody([foe], foe.x + 0.48 * foe.s, foe.y + 0.35 * foe.s, { row: 9, col: 5 }), null);
});
test('the HUD (tier chip / bars) is an outline: its own unit when nothing else is there, never over a torso', () => {
const B = { key: 'B', ...at(10, 5) }, C = { key: 'C', ...at(11, 5) };
const chipY = B.y - 1.3 * B.s;
B.hud = { x0: B.x - 12, y0: chipY - 12, x1: B.x + 12, y1: chipY + 12 };
assert.equal(pickBody([B], B.x, chipY, null), B, 'alone: the chip is its unit\'s');
assert.equal(hitBody(C, B.x, chipY), 2, 'premise: C\'s legs are behind B\'s chip');
assert.equal(pickBody([B, C], B.x, chipY, { row: 11, col: 5 }), C);
});
test('model height, width factor and facing shape the outline', () => {
const tall = { ...at(10, 5), h: 1.45 }, short = { ...at(10, 5), h: 1.1 };
const topOf = (b) => bodyBounds(b).y;
assert.ok(topOf(tall) < topOf(short), 'taller model → higher outline');
assert.ok(Math.abs(topOf(tall) - (tall.y - 1.45 * tall.s)) < 1, 'the outline tops out at the model height');
const big = { ...at(10, 5), h: 2.4, w: 2 };
assert.ok(bodyBounds(big).width > 1.9 * bodyBounds(short).width, 'enemies scale with their size');
const right = bodyParts({ ...at(10, 5), flip: 1 }).outline[1].rect, left = bodyParts({ ...at(10, 5), flip: -1 }).outline[1].rect;
const x = at(10, 5).x;
assert.ok(x - right.x0 > right.x1 - x && left.x1 - x > x - left.x0, 'the back side (hair, capes) is the wider one');
});
test('avatar diamonds and item plates', () => {
const d = { kind: 'diamond', ...at(10, 5), d: 0.78 };
const centre = d.y - (0.08 + 0.39) * d.s;
assert.equal(hitBody(d, d.x, centre), 2);
assert.equal(hitBody(d, d.x + 0.33 * d.s, centre), 1, 'near its tip');
assert.equal(hitBody(d, d.x + 0.45 * d.s, centre), 0);
const plate = { kind: 'plate', x: 500, y: 300, s: 100, r: 0.31 };
assert.equal(hitBody(plate, 500, 300), 2);
assert.equal(hitBody(plate, 528, 300), 1);
assert.equal(hitBody(plate, 540, 300), 0);
assert.deepEqual(bodyBounds(plate), { x: 469, y: 269, width: 62, height: 62 });
assert.equal(coreDistance(plate, 500, 300), 0);
assert.ok(Math.abs(coreDistance(plate, 541.7, 300) - 0.2) < 1e-6, 'px to the core / px per tile');
});
test('junk input picks nothing', () => {
const U = { key: 'U', ...at(10, 5) };
assert.equal(pickBody([U], NaN, 3), null);
assert.equal(pickBody(null, 3, 3), null);
assert.equal(pickBody([null, { x: 1, y: 1, s: 0 }, { x: NaN, y: 1, s: 5 }], 1, 1, { row: 1, col: 1 }), null);
assert.equal(hitBody(null, 0, 0), 0);
});
});
// Review of item 7: measured against the rendered pixels of the mock match (every drawn unit pixel of the prep board at
// 1920×1080), the shape rule alone put ~8 % of the presses on the wrong unit — mostly the unit BEHIND: the hair crown
// and the sides (arms, clothes, weapons) of the unit in front lie over the legs / tile of the one behind. The pixel
// probe (render/app.js drawnAt) settles every overlap by what is really drawn on top.
describe('pickBody with the pixel probe', () => {
const { A, B, cam } = pair('prep', 1920, 1080);
const ground = (x, y) => { const g = cam.unproject(x, y, 0); return g ? { row: Math.round(g.y), col: Math.round(g.x) } : null; };
/** A probe that says which bodies draw the point (and records who was asked, in order). */
const probeOf = (drawing, calls = []) => (b) => { calls.push(b.key); return drawing.includes(b.key); };
test('A\'s hair crown drawn over B\'s torso: A (the shape rule alone says B)', () => {
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B', 'premise: the rule gives it to B');
const calls = [];
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A', 'B'], calls))?.key, 'A');
assert.deepEqual(calls, ['A'], 'front-most first; stops at the first that draws');
});
test('B\'s legs showing beside A\'s head: B', () => {
const x = B.x + 0.3 * B.s, y = B.y - 0.3 * B.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['B']))?.key, 'B');
});
test('A\'s arm over the tile of B, outside B\'s outline: A (a single candidate the rule did not pick)', () => {
// a point on B's tile in front of B's feet, beside A's body: the rule picks B (standing on the pressed tile)
const t = cam.project(COL, 10, 0);
const x = t.x + 0.45 * t.s, y = t.y + 0.3 * t.s;
assert.equal(ground(x, y)?.row, 10, 'premise: B\'s tile');
assert.equal(pickBody([A, B], x, y, ground(x, y))?.key, 'B');
const cand = probeCandidates([A, B], x, y);
if (cand.length === 1) assert.equal(cand[0].key, 'A');
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf(['A']))?.key, 'A');
// nothing drawn there: the rule (B's tile)
assert.equal(pickBody([A, B], x, y, ground(x, y), probeOf([]))?.key, 'B');
});
test('the probe is only asked where it can change the answer; null / throwing → the shape rule', () => {
const lone = { ...A, key: 'L' };
const calls = [];
assert.equal(pickBody([lone], lone.x, lone.y - 0.5 * lone.s, null, probeOf([], calls))?.key, 'L');
assert.deepEqual(calls, [], 'one body that the rule picks: no probe');
assert.equal(pickBody([lone], lone.x + 5 * lone.s, lone.y, null, probeOf([], calls)), null);
assert.deepEqual(calls, [], 'no body around: no probe');
const x = A.x, y = A.y - 1.2 * A.s;
assert.equal(pickBody([A, B], x, y, ground(x, y), () => null)?.key, 'B', 'cannot tell → the rule');
assert.equal(pickBody([A, B], x, y, ground(x, y), () => { throw new Error('lost context'); })?.key, 'B');
// the one in front cannot tell: the rule decides (never a unit behind an unknown one)
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true))?.key, 'B');
assert.equal(pickBody([A, B], x, y, ground(x, y), (b) => (b.key === 'A' ? null : true)), pickBody([A, B], x, y, ground(x, y)));
});
test('probe candidates: bodies whose outline + PROBE_MARGIN holds the point, front-most first', () => {
const r = bodyBounds({ ...A, hud: null });
assert.deepEqual(probeCandidates([B, A], A.x, A.y - 0.9 * A.s).map((b) => b.key), ['A', 'B']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN - 0.05) * A.s, A.y - 0.5 * A.s).map((b) => b.key), ['A']);
assert.deepEqual(probeCandidates([A], r.x - (PROBE_MARGIN + 0.05) * A.s, A.y - 0.5 * A.s), []);
assert.deepEqual(probeCandidates([null, { x: NaN, y: 0, s: 1 }], 0, 0), []);
});
});
+80
View File
@@ -195,6 +195,86 @@ describe('field view teardown (app.js releaseGl)', () => {
});
});
// user playtest #3 item 7: the body as drawn, for render/pick.js (the shared picking rule)
describe('pickShape / bounds / hitTest', () => {
test('the avatar diamond until the Spine model shows, then the chibi at its own measured height', async () => {
const v = view({ defId: 'char_p1', avatar: 'char_p1' }, { prep: true }, store({ spine: true, imageDelay: 50 }));
v.update(1 / 60, cam(), 0);
assert.equal(v.pickShape().kind, 'diamond', 'no model yet');
await tick(); await tick();
assert.ok(v.spineReady);
// the posed skeleton's bounds: 425.6 units above the feet = 1.33 tiles (UNIT.modelScale 1/320)
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -425.6, width: 120, height: 430 });
v.update(1 / 60, cam(), 1 / 60);
const b = v.pickShape();
assert.equal(b.kind, 'chibi');
assert.ok(Math.abs(b.h - 1.33) < 1e-6, `measured height ${b.h}`);
assert.equal(b.w, 1, 'operators: the measured width profile');
assert.equal(b.x, v.screen.x); assert.equal(b.y, v.screen.y); assert.equal(b.s, v.screen.s);
assert.equal(b.depth, v.root.zIndex);
// measured once: a later pose (a raised weapon) does not move the outline
v.actor.spine.getLocalBounds = () => ({ x: -60, y: -700, width: 120, height: 700 });
v.update(1 / 60, cam(), 2 / 60);
assert.ok(Math.abs(v.pickShape().h - 1.33) < 1e-6);
// the outline tops out at the model; the tier chip (prep) is part of the pick shape, not of bounds()
const r = v.bounds();
assert.ok(Math.abs(r.y - (v.screen.y - 1.33 * v.screen.s)) < 1, 'bounds top = model top');
assert.ok(Math.abs(r.y + r.height - (v.screen.y + 0.08 * v.screen.s)) < 1, 'bounds bottom = just below the feet');
assert.ok(b.hud && b.hud.y1 < v.screen.y - v.screen.s, 'the chip is up at the head (UNIT.headroom)');
assert.ok(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), 'the torso');
assert.ok(!v.hitTest(v.screen.x + 0.6 * v.screen.s, v.screen.y - 0.5 * v.screen.s), 'beside it');
// a new model (Front ⇄ Back swap, _dropActor) is measured again
v._dropActor();
assert.equal(v._bodyH, null);
});
test('implausible skeleton bounds are clamped; enemies scale their width with their size', async () => {
const op = view({ defId: 'char_p2', avatar: 'char_p2' }, { prep: true }, store({ spine: true }));
await tick(); await tick();
op.actor.spine.getLocalBounds = () => ({ x: -500, y: -1200, width: 1000, height: 1200 }); // an effect in the idle pose
op.update(1 / 60, cam(), 0);
assert.equal(op.pickShape().h, 1.55, 'operators ≤ 1.55 tiles');
const foe = view({ side: 'enemy', kind: 'enemy', defId: 'enemy_big' }, {}, store({ spine: true }));
await tick(); await tick();
foe.actor.spine.getLocalBounds = () => ({ x: -300, y: -640, width: 600, height: 640 });
foe.update(1 / 60, cam(), 0);
const b = foe.pickShape();
assert.ok(Math.abs(b.h - 2) < 1e-6);
assert.ok(Math.abs(b.w - 2 / 1.2) < 1e-6, 'width factor from the height');
assert.equal(b.hud, null, 'an undamaged enemy shows no bar');
});
test('a battle unit\'s bars are its HUD rect; a dead unit is not hit', async () => {
const v = view({ defId: 'char_p3', avatar: 'char_p3' }, {}, store({ spine: true }));
await tick(); await tick();
v.sync({ x: 5, y: 12, hp: 500, maxHp: 1000, sp: 3, spMax: 10, flags: 0, anim: 0, vx: 0 });
v.update(1 / 60, cam(), 0);
const hud = v.pickShape().hud;
assert.ok(hud && hud.x1 > hud.x0 && hud.y1 > hud.y0, 'HP / SP bars and chip');
assert.ok(hud.y1 < v.screen.y - v.screen.s, 'above the head');
v.die();
assert.equal(v.hitTest(v.screen.x, v.screen.y - 0.5 * v.screen.s), false);
});
test('item plates: floating above the slot; centred on the pointer while dragged (lifted)', async () => {
const { ItemView } = await import('../../public/js/render/units.js');
const ctx = fakeViewCtx(fake.P, { assets: store(), cam });
const it = new ItemView(ctx, { id: 'p:9', uid: 9, defId: 'item_x', x: 3, y: 7 });
it.setWorld(3, 7, 0.16);
it.update(1 / 60, cam(), 0);
let b = it.pickShape();
assert.equal(b.kind, 'plate');
assert.ok(Math.abs(b.y - (it.screen.y - 0.31 * it.screen.s)) < 1e-6, 'resting: the plate above its anchor');
it.lift = 0.3;
it.update(1 / 60, cam(), 0);
b = it.pickShape();
const g = cam().project(3, 7, 0.16);
assert.ok(Math.abs(b.x - g.x) < 1e-6 && Math.abs(b.y - g.y) < 1e-6, 'dragged: centred on its ground point (the pointer)');
assert.equal(it.plate.anchor.y, 0.5);
assert.ok(it.hitTest(g.x, g.y));
});
});
describe('enemy preview pen figures (lod idle)', () => {
/** A UnitView of a pen figure with a Spine model, an impostor atlas (full or not) and a counting renderer. */
async function penFigure(full) {
+67
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { SUB, resolveProfile } from '../../server/sim/professions.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
@@ -221,6 +222,72 @@ test('stalker dodges and hits everything in range; hunter consumes and reloads a
assert.ok(hu.trait.ammo >= 2);
});
// 回环射手 (loopshooter, 跃跃): "持有回旋投射物时才能够攻击(投射物需要时间回收)" — out at 15 tiles/s, back at 3.75
// (PRTS 跃跃 特性 note); a 0.2 s attack interval so the boomerang, not the cooldown, sets the pace
const LOOP = () => mk('loopshooter', 'SNIPER', { id: 't_loop', attackKind: 'ranged', projectile: 'arrow', canHitFly: true, stats: { atk: 100, bat: 0.2, respawnTime: 5 } });
test('loopshooter: the boomerang flies out (15 tiles/s), hits on arrival, flies back (3.75 tiles/s); the next attack waits for the catch', () => {
const p = resolveProfile({ profession: 'SNIPER', subProf: 'loopshooter', attackKind: 'ranged', dmgType: 'phys', projectile: 'arrow', traitBb: {} });
assert.equal(p.projectile, 'boomerang', "the data's generic 'arrow' never replaces the boomerang");
assert.equal(PROJECTILE_SPEEDS.boomerang, 15);
assert.equal(BOOMERANG_RETURN_SPEED, 3.75);
for (const col of [7, 5]) { // 3 tiles / 1 tile away
const h = makeBattle({
defs: { chess: { t_loop: LOOP() }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_loop', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, col] }],
content: 'none', captureNoisy: true, hooks: ['attack', 'damaged'], autoFinish: false,
});
h.run(8);
const u = h.unit('t_loop');
const d = col - 4;
const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
const at = h.hooksOf('attack').filter((c) => c.attacker === u).map((c) => c.t);
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
assert.ok(at.length >= 5, `${d} tiles: ${at.length} attacks`);
for (let i = 1; i < at.length; i++) {
const gap = at[i] - at[i - 1];
assert.ok(gap >= flight - 1e-9 && gap <= flight + 3 * h.TICK, `${d} tiles: gap ${gap.toFixed(3)} ≈ out + back ${flight.toFixed(3)}`);
}
// one hit per throw (the way back deals nothing), landing out-flight time after the throw
const landed = at.filter((t) => t + d / PROJECTILE_SPEEDS.boomerang + 2 * h.TICK < h.b.time).length;
assert.ok(hits.length >= landed && hits.length <= at.length, `one hit per boomerang (${hits.length} hits, ${at.length} throws)`);
const lag = hits[0].t - at[0];
assert.ok(lag >= d / PROJECTILE_SPEEDS.boomerang - 2 * h.TICK && lag <= d / PROJECTILE_SPEEDS.boomerang + h.TICK, `hit ${lag.toFixed(3)} s after the throw`);
const atk = h.eventsOf('atk').filter((e) => e[1] === u.id);
assert.ok(atk.length && atk.every((e) => e[3] === 'boomerang'), "b.ev ['atk', thrower, target, 'boomerang']");
checkInvariants(h.b);
}
});
test('loopshooter: a target killed mid-flight is not hit, the boomerang still comes back; a knocked-out thrower gets a fresh one on redeploy', () => {
const h = makeBattle({
defs: { chess: { t_loop: LOOP() }, enemies: { enemy_dummy: dummy(), enemy_dummy2: dummy({ key: 'enemy_dummy2', hp: 1e7 }) } },
units: [{ chessId: 't_loop', row: 10, col: 4 }], enemies: [{ key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy2', pos: [10, 7] }],
content: 'none', captureNoisy: true, hooks: ['attack', 'damaged'], autoFinish: false,
});
const u = h.unit('t_loop');
assert.ok(h.runUntil(() => u.stats.attacks === 1, 2));
const first = h.hooksOf('attack')[0].targets[0];
assert.equal(u.trait.boomerangsOut, 1);
h.b.kill(first);
h.run(0.5);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === first && c.source === u).length, 0, 'the dead target took nothing');
assert.equal(u.stats.attacks, 1, 'still waiting for the boomerang');
assert.ok(h.runUntil(() => u.stats.attacks === 2, 1.5), 'caught at the thrower → the next throw');
const second = h.hooksOf('attack')[1].targets[0];
assert.notEqual(second, first);
// knocked out with the boomerang in flight: it is lost; the redeployed thrower holds a fresh one
assert.equal(u.trait.boomerangsOut, 1);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
h.run(2);
assert.equal(u.stats.attacks, 2);
assert.ok(h.runUntil(() => u.alive, 10), 'redeployed');
assert.equal(u.trait.boomerangsOut, 0, 'a fresh boomerang');
assert.ok(h.runUntil(() => u.stats.attacks === 3, 1), 'attacks at once');
checkInvariants(h.b);
});
test('fortress: melee on blocked enemies, ranged splash otherwise; tactician calls a reinforcement', () => {
const fort = mk('fortress', 'TANK', { attackKind: 'ranged', projectile: 'arrow', canHitFly: true, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], stats: { atk: 500, blockCnt: 3, maxHp: 1e6 } });
const h = makeBattle({
+65 -1
View File
@@ -12,8 +12,9 @@ import {
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, pickPieceAt, equipRetarget,
} from '../../public/js/ui/gameLogic.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
@@ -492,3 +493,66 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
assert.deepEqual(prepCamera(pub(14, [pl('a', 0)]), 'a'), { kind: 'bossPrep', opts: { side: 'L' } });
});
});
// user playtest #3 item 7: equipment dropped on the operator standing behind another selected the one in front
describe('pickPieceAt / equipRetarget (the unit under a dragged item)', () => {
// two operators in one column on the official prep camera: uid 1 on row 9 (front), uid 2 on row 10 (behind, same
// height); `shape` = the drawn body the render engine reports through view.pieceScreenRect
const cam = presetCamera('prep', { width: 1920, height: 1080 });
const unit = (uid, row, col) => {
const p = cam.project(col, row, 0);
const shape = { kind: 'chibi', x: p.x, y: p.y, s: p.s, h: 1.27, flip: 1, depth: -cam.depthOf(col, row, 0) * 100 + col * 0.001 };
// the pre-fix rect (0.7 × 1.28 tiles) — plain rects are what the DOM fallback reports
const rect = { left: p.x - 0.35 * p.s, right: p.x + 0.35 * p.s, top: p.y - 1.18 * p.s, bottom: p.y + 0.1 * p.s };
return { uid, row, col, p, shape, rect };
};
const A = unit(1, 9, 4), B = unit(2, 10, 4);
const engine = [A, B].map((u) => ({ uid: u.uid, ...u.rect, shape: u.shape, tile: { row: u.row, col: u.col } }));
const plain = [A, B].map((u) => ({ uid: u.uid, ...u.rect }));
const up = (u, k, dx = 0) => [u.p.x + dx * u.p.s, u.p.y - k * u.p.s];
test('engine shapes: the face / torso of each is its own; the lower half of the unit behind is no longer the front one\'s', () => {
for (const k of [1.0, 0.6, 0.35]) assert.equal(pickPieceAt(engine, ...up(B, k), { row: 10, col: 4 }), 2, `B ${k} tile up`);
for (const k of [0.86, 0.5, 0.1]) assert.equal(pickPieceAt(engine, ...up(A, k), { row: 9, col: 4 }), 1, `A ${k} tile up`);
// B's tile showing beside A's head: B (the old front-most rect gave A)
const side = up(B, 0, 0.3);
assert.equal(pickPieceAt(engine, ...side, { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(plain, ...side), 1, 'plain rects: the overlap still favours the nearer centre');
// off both bodies: the tile under the point decides
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 4 }), 2);
assert.equal(pickPieceAt(engine, ...up(B, 0, 0.47), { row: 10, col: 5 }), null);
});
test('plain rects (DOM fallback): the rect whose centre is nearest relative to its size, ties → the front-most', () => {
const rects = [
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // front (lower on screen)
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // behind
];
assert.equal(pickPieceAt(rects, 130, 450), 1, 'nearer the front one\'s centre');
assert.equal(pickPieceAt(rects, 130, 430), 2, 'nearer the one behind (the front-most rule gave 1)');
assert.equal(pickPieceAt(rects, 130, 435), 1, 'equidistant: the front-most');
assert.equal(pickPieceAt(rects, 130, 360), 2);
assert.equal(pickPieceAt(rects, 10, 10), null);
assert.equal(pickPieceAt(rects, NaN, 10), null);
assert.equal(pickPieceAt(null, 1, 1), null);
assert.equal(pickPieceAt([{ uid: 'x', left: 0, right: 9, top: 0, bottom: 9 }, null], 5, 5), null);
});
test('equipRetarget sends the item to the unit the engine picked (hitUid), Arts never', () => {
const priv = {
alive: true, ready: false, funds: 10, deployCap: 8, deployCount: 2,
board: [piece(RANGED, { row: 9, col: 3 }), piece(RANGED, { row: 10, col: 3 })],
hand: [item(EQUIP), item(MAGIC), ...new Array(GEO.HAND_SIZE - 2).fill(null)],
temp: new Array(GEO.TEMP_SIZE).fill(null), shop: { slots: [] },
};
const ctx = placementContext({ priv, stage: STAGE, editable: true, getChess, getToken, getItem });
const [front, behind] = priv.board;
const [eq, art] = priv.hand;
const onFrontTile = { area: 'board', row: 9, col: 3 };
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, behind.uid), { area: 'board', row: 10, col: 3 });
assert.deepEqual(dropIntent(ctx, eq.uid, equipRetarget(ctx, eq.uid, onFrontTile, behind.uid)).fields, { itemUid: eq.uid, targetUid: behind.uid });
assert.deepEqual(equipRetarget(ctx, art.uid, onFrontTile, behind.uid), onFrontTile, 'an Art is used on its tile');
assert.deepEqual(equipRetarget(ctx, eq.uid, onFrontTile, null), onFrontTile);
assert.deepEqual(equipRetarget(ctx, front.uid, onFrontTile, behind.uid), onFrontTile, 'units are not retargeted');
});
});
+466
View File
@@ -0,0 +1,466 @@
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/Match.js _pendingLpView)
// 3 the temp overflow row (临时整备区): the ready button's visible reason, the row's frame geometry (tempRowFrame)
// 8 the operator's own effect (特质 / garrison) right under the detail card's header (CHESS_SECTIONS, rendered order)
// 9 no spinning busy indicators next to texts: no wait / progress cursors, the button's busy bar instead of a
// spinning ring, the 机变 card's 选择中 strip (never stuck), a still hourglass; the data store retries a transient
// failure and every data file the match UI reads is one the match screen waits for (GAME_FILES); the 特质 chip's
// icon is the garrison's official type icon (the spoked 特异化 glyph only on 特异化 特质, not on every 休整期 one)
// (browser counterparts: the scratch checks of the playtest-3 workstream; the E2E suites cover the flows)
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PHASE, GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk (ChessDetail / ChoiceOverlay renders below)
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { pendingLoss, liveLp, ownLeaks, pendingTip, tempInfo, tempReadyReason, ReadyToggle, TEMP_RULE } = await import('../../public/js/ui/hud.js');
const { rowLp } = await import('../../public/js/ui/teamPanel.js');
const { LpTower, GAME_FILES } = await import('../../public/js/ui/gameComponents.js');
const { tempRowFrame, tempRowRule } = await import('../../public/js/ui/underframe.js');
const { ChessDetail, CHESS_SECTIONS, garrisonTypeIconKey } = await import('../../public/js/ui/detailPanel.js');
const { ChoiceOverlay, pickBusy } = await import('../../public/js/ui/choiceOverlay.js');
const { Button, Spinner } = await import('../../public/js/ui/components.js');
const { data, createDataStore, RETRY_DELAYS_MS } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
/** The text of a vnode tree in document order. */
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
assert.equal(pendingLoss(10), 10);
assert.equal(pendingLoss(14), 10, 'capped (config lpCapPerRound 10)');
assert.equal(pendingLoss(14, 12), 12, 'the configured cap');
for (const junk of [-3, NaN, null, undefined, 'x']) assert.equal(pendingLoss(junk), 0, String(junk));
const cfg = JSON.parse(read('data/config.json'));
assert.equal(cfg.lpCapPerRound, 10, 'the cap the client reads (gd.config.lpCapPerRound)');
});
test('the own count: the further of the local runner\'s and the server\'s (a display replica stands still while the player watches a teammate)', () => {
assert.equal(ownLeaks(3, 1), 3, 'the local battle is ahead of the ~1 Hz b.progress');
assert.equal(ownLeaks(2, 5), 5, 'a display replica paused while watching another field: the server moved on');
assert.equal(ownLeaks(undefined, 4), 4, 'no local entry (reconnect, still loading)');
assert.equal(ownLeaks(4, undefined), 4);
for (const [a, b] of [[undefined, undefined], [NaN, null], [-2, 'x']]) assert.equal(ownLeaks(a, b), 0);
// the game screen feeds liveLp with it
assert.match(read('public/js/screens/game.js'), /leaks: ownLeaks\(localLeaks, meP\?\.pendingLp\)/);
});
/** Feed liveLp a sequence of states, keeping its base like the game screen (lpBaseRef). */
const run = (steps) => {
let base = null;
return steps.map((s) => { const r = liveLp(base, s); base = r.base; return r; });
};
test('COMBAT: lp − pending as leaks come in; the settled m.private (sent before the SETTLE m.public) drops the pending part — never subtracted twice', () => {
const at = (o) => ({ phase: PHASE.COMBAT, round: 5, lp: 30, statsLeaks: 7, leaks: 0, cap: 10, ...o });
const out = run([
at({}),
at({ leaks: 1 }),
at({ leaks: 3 }),
// the settlement: m.private (lp 27, stats.leaks 10) lands while m.public still says COMBAT (Match.flush order)
at({ leaks: 3, lp: 27, statsLeaks: 10 }),
// then the SETTLE m.public
{ phase: PHASE.SETTLE, round: 5, lp: 27, statsLeaks: 10, leaks: 3, cap: 10 },
// the next round
{ phase: PHASE.PREP, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 0, cap: 10 },
{ phase: PHASE.COMBAT, round: 6, lp: 27, statsLeaks: 10, leaks: 2, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [30, 29, 27, 27, 27, 27, 27, 25]);
assert.deepEqual(out.map((r) => r.pending), [0, 1, 3, 0, 0, 0, 0, 2]);
assert.ok(out.every((r) => r.unite === false));
});
test('a settlement that charged nothing (a 联防 saved everything) is seen through stats.leaks; the SETTLE phase alone drops it too', () => {
const at = (o) => ({ phase: PHASE.UNITE, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10, ...o });
const out = run([
{ phase: PHASE.COMBAT, round: 8, lp: 20, statsLeaks: 4, leaks: 5, cap: 10 },
at({}),
at({ statsLeaks: 9 }), // settled: lp unchanged (0 lost), the counted leaks recorded
{ phase: PHASE.SETTLE, round: 8, lp: 20, statsLeaks: 9, leaks: 5, cap: 10 },
]);
assert.deepEqual(out.map((r) => r.shown), [15, 15, 20, 20]);
assert.deepEqual(out.map((r) => r.unite), [false, true, false, false], '联防中 while the 联防 may still save part of it');
// without stats (older view): the SETTLE phase ends it
const bare = run([{ phase: PHASE.UNITE, round: 8, lp: 20, leaks: 5 }, { phase: PHASE.SETTLE, round: 8, lp: 20, leaks: 5 }]);
assert.deepEqual(bare.map((r) => r.shown), [15, 20]);
});
test('clamped at 0; nothing pending for the eliminated, in prep, in boss rounds or without an LP', () => {
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).shown, 0);
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 4, leaks: 9 }).pending, 4, 'never more than the LP left');
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: 40, leaks: 9, alive: false }).pending, 0);
for (const phase of [PHASE.PREP, PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE, PHASE.RESULT]) {
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
}
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
assert.equal(pendingTip(30, 0), null);
});
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
const pub = { phase: PHASE.COMBAT };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
const v = LpTower({ value: 24, size: 'lg', pending: 3, tip: 'x' });
assert.ok(hasClass(v, 'is-pending'));
const pend = [...walk(v)].find((n) => hasClass(n, 'lp__pend'));
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
test('server: m.public players[].pendingLp = min(cap, the authority\'s reported leaks) during COMBAT, the own battle\'s count in 联防, gone at SETTLE', async () => {
const { makeMatch } = await import('../match/harness.js');
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 0, seed: 7331, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1);
const m = h.m;
const view = (pid) => m.publicView().players.find((p) => p.playerId === pid);
const f = m.fields.find((x) => x.players.includes('p_0'));
assert.equal(f.mode, 'client');
assert.equal('pendingLp' in view('p_0'), false, 'omitted while nothing leaked');
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 5, killed: 1, total: 9, leaks: 3 });
assert.equal(view('p_0').pendingLp, 3);
assert.equal('pendingLp' in view('p_1'), false);
m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 9, killed: 1, total: 20, leaks: 14 });
assert.equal(view('p_0').pendingLp, m.gd.lpCapPerRound, 'capped');
// 联防: the recorded own results
m.phase = PHASE.UNITE;
m.lastResults = new Map([['p_0', { leaked: [{ enemyKey: 'a' }, { enemyKey: 'b', counted: false }, { enemyKey: 'c', counted: true }] }]]);
assert.equal(view('p_0').pendingLp, 2, 'counted !== false, like settle()');
assert.equal('pendingLp' in view('p_1'), false);
for (const phase of [PHASE.SETTLE, PHASE.PREP, PHASE.FINAL_ASSAULT]) {
m.phase = phase;
assert.equal('pendingLp' in view('p_0'), false, phase);
}
m.dispose();
});
});
// ---- 3: the temp overflow row --------------------------------------------------------------------------------------
describe('3: the temp overflow row (临时整备区)', () => {
const priv = (temp) => ({ alive: true, ready: false, canReady: !temp.some(Boolean), temp });
test('the ready button shows why it is refused, under it, not only on hover', () => {
const p = priv([{ uid: 1, kind: 'item', id: 'x' }, { uid: 2, kind: 'chess', id: 'y' }, null, null, null]);
assert.deepEqual(tempInfo(p), { count: 2, items: 1 });
assert.match(tempReadyReason(p), /^临时整备区还有 2 个单位:/);
assert.ok(tempReadyReason(p).includes(TEMP_RULE));
assert.match(TEMP_RULE, /休整期结束时.*销毁/, 'says what happens at the end of the prep');
const v = ReadyToggle({ priv: p, onToggle() {}, readyCount: 1, total: 4 });
const why = [...walk(v)].find((n) => hasClass(n, 'readywrap__why'));
assert.ok(why, 'visible reason');
assert.equal(textOf(why), '临时整备区 2 个单位待处理');
const btn = [...walk(v)].find((n) => n.type === 'button');
assert.equal(btn.props.disabled, true);
// empty temp: no reason, enabled
const ok = ReadyToggle({ priv: priv([null, null, null, null, null]), onToggle() {} });
assert.equal([...walk(ok)].find((n) => hasClass(n, 'readywrap__why')), undefined);
assert.ok(![...walk(ok)].find((n) => n.type === 'button').props.disabled);
assert.deepEqual(tempInfo(null), { count: 0, items: 0 });
});
test('the row frame: the outer corners of both end tiles, the label on the side with room (mirrored Final Assault prep too)', () => {
// tileScreen polys: [back-left, back-right, front-right, front-left] (render/app.js tileScreen corner order)
const tile = (cx, y, w = 100, d = 60) => ({ x: cx, y, s: w, poly: [[cx - w / 2 + 6, y - d / 2], [cx + w / 2 - 6, y - d / 2], [cx + w / 2, y + d / 2], [cx - w / 2, y + d / 2]] });
const a = tile(800, 640), b = tile(1260, 640);
const f = tempRowFrame(a, b, { labelW: 290, gap: 10, vw: 1920 });
assert.deepEqual(f.quad, [[756, 610], [1304, 610], [1310, 670], [750, 670]]);
assert.deepEqual([f.left, f.right, f.top, f.bottom, f.y], [750, 1310, 610, 670, 640]);
assert.equal(f.side, 'left');
// the Final Assault prep of the right-hand player draws the board mirrored: the ends swap on screen
assert.deepEqual(tempRowFrame(b, a, { labelW: 290, gap: 10, vw: 1920 }).quad, f.quad);
// no room on the left (a narrow phone): the label goes right
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 1200 }).side, 'right');
// neither side has room: left (clipped rather than covering the row)
assert.equal(tempRowFrame(tile(200, 300), tile(600, 300), { labelW: 290, gap: 10, vw: 900 }).side, 'left');
for (const bad of [null, {}, { poly: [[0, 0]] }, { poly: [[0, 0], [1, 0], [1, NaN], [0, 1]] }]) assert.equal(tempRowFrame(bad, b), null);
});
test('the row\'s label matches the server\'s due rule (PlayerState tempDue): destroyed at this prep\'s end unless ready — then kept through the next prep', () => {
assert.match(tempRowRule(false), /才能准备;休整期结束时仍在此处的将被销毁$/);
// Ready is refused while the row holds pieces: what lies there while ready arrived after it (due at the next prep)
assert.match(tempRowRule(true), /保留到下个休整期/);
assert.match(tempRowRule(true), /取消准备则在本休整期结束时销毁/);
});
test('the game screen frames the row only on the own prep board while it holds pieces (source contract)', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /const tempNotice = temp\.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;/);
assert.match(game, /<\$\{TempRowNotice\} view=\$\{view\} count=\$\{temp\.count\} items=\$\{temp\.items\} label=\$\{!drag && !facing\}\s+ready=\$\{phase === PHASE\.PREP && !!priv\?\.ready\} \/>/);
assert.match(game, /toast\(tempReadyReason\(L\.priv\) \|\| refused, 'warn'\)/, 'Space says why');
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.tempnote \{ position: fixed; inset: 0; z-index: 2; pointer-events: none; \}/, 'over the field, under the HUD, never takes the pointer');
});
test('while the ready button shows its reason the effects column moves down a line (on phones the ready count ran into it)', () => {
const game = read('public/js/screens/game.js');
// the same condition as ReadyToggle's reason: PREP, alive, not ready, pieces in the row
assert.match(game, /const readyWhy = phase === PHASE\.PREP && alive && !priv\?\.ready && temp\.count > 0;/);
assert.match(game, /readyWhy && 'has-readywhy'/);
const css = read('public/css/screens/game.css');
assert.match(css, /\.gm__effects \{ position: absolute; right: \.3rem; top: 2\.4rem; \}/);
assert.match(css, /\.gm\.has-readywhy \.gm__effects \{ top: calc\(2\.4rem \+ max\(\.3rem, 16px\)\); \}/);
});
});
// ---- 8: the operator's own effect first --------------------------------------------------------------------------------
describe('8: 特质 right under the detail card\'s header', () => {
test('the block order', () => {
assert.deepEqual(CHESS_SECTIONS, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
});
test('a rendered operator card: header, then 特质 (trigger + description), then the rest in CHESS_SECTIONS order', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const c = data.lookup('chess', 'chess_char_3_04_b'); // 琳琅诗怀雅 (elite): <休整期结束时> garrison, modules, talents
assert.ok(c && c.garrisonIds && c.garrisonIds[0]);
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventType, 'SERVER_PREP_FIN');
const blocks = ChessDetail({ chess: c, piece: { uid: 7, kind: 'chess', id: c.chessId, golden: true, items: [] }, editable: false, onSell() {}, bonds: [], loadout: null, onBond: null });
const keys = blocks.map((b) => b.key);
assert.deepEqual(keys, ['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents']);
assert.equal(blocks[1].props.garrison, g, 'the operator\'s own garrison');
// the garrison block itself: the 特质 label, the trigger chip and the description
const gb = blocks[1].type(blocks[1].props);
assert.ok(hasClass(gb, 'dgarrison'));
assert.ok(textOf(gb).includes('特质'));
assert.ok(textOf(gb).includes(g.eventTypeDesc));
const rich = [...walk(gb)].find((n) => n.props?.text != null);
assert.equal(rich.props.text, g.descRaw || g.desc);
// an operator without a garrison (a summon's chess record may have none) simply starts with the trait
const bare = ChessDetail({ chess: { ...c, garrisonIds: [] }, piece: null, editable: false, bonds: [], loadout: null });
assert.deepEqual(bare.map((b) => b.key), ['head', 'trait', 'stats', 'skill', 'module', 'talents']);
});
test('the 特质 chip carries the garrison\'s official type icon (eventTypeIcon) — the spoked 特异化 glyph only on 特异化', async () => {
await data.loadAll('garrisons', 'assets', 'chess');
const all = data.list('garrisons');
assert.ok(all.length > 200);
const bad = all.filter((g) => garrisonTypeIconKey(g) !== `s_${g.eventTypeIcon}`);
assert.deepEqual(bad.map((g) => g.garrisonId), [], 'every garrison shows its own type icon');
// (the spoked icon_support reads like a loading spinner: it used to stand before every <休整期开始时 / 结束时> 特质)
const spoked = all.filter((g) => garrisonTypeIconKey(g) === 's_icon_support');
assert.ok(spoked.length > 0 && spoked.every((g) => g.eventTypeDesc === '特异化'), 'icon_support = 特异化 only');
const prep = all.filter((g) => g.eventType === 'SERVER_PREP_START' || g.eventType === 'SERVER_PREP_FIN');
assert.ok(prep.filter((g) => garrisonTypeIconKey(g) !== 's_icon_support').length > prep.length / 2, 'most 休整期 特质 are 持续叠加 / 整备能力');
// no official icon in the record: a guess by the trigger, never a path from the data
assert.equal(garrisonTypeIconKey({ eventType: 'SERVER_PREP_FIN' }), 's_icon_bond');
assert.equal(garrisonTypeIconKey({ eventType: 'IN_BATTLE', eventTypeIcon: '../../x' }), 's_icon_battle');
assert.equal(garrisonTypeIconKey(null), 's_icon_bond');
// the rendered card of 琳琅诗怀雅 (elite): <休整期结束时> · 整备能力 → the gold 整备能力 icon
const c = data.lookup('chess', 'chess_char_3_04_b');
const g = data.lookup('garrisons', c.garrisonIds[0]);
assert.equal(g.eventTypeDesc, '整备能力');
const blocks = ChessDetail({ chess: c, piece: null, editable: false, bonds: [], loadout: null });
const gb = blocks.find((b) => b.key === 'garrison');
const img = [...walk(gb.type(gb.props))].find((n) => hasClass(n, 'dgarrison__icon'));
assert.match(String(img.props.src), /garrisonTypeIcon\/s_icon_gold\.png$/);
});
test('css: a compact gold-accented block; stats in a 4 × 2 grid so the skill stays close', () => {
const css = read('public/css/screens/game-panels.css');
assert.match(css, /\.dgarrison \{[^}]*border-left: 3px solid var\(--gold-2\)/);
assert.match(css, /\.dstats \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/);
assert.match(read('public/css/devices.css'), /@media \(max-height: 460px\) \{\n {2}\.dhead__art \{ height: 1\.66rem; \}\n {2}\.dhead__en \{ display: none; \}/);
});
});
// ---- 9: no spinner next to texts; data loaded once, before the match screen --------------------------------------------
describe('9: busy indicators and data loading', () => {
const cssFiles = () => {
const out = [];
const walkDir = (d) => { for (const e of readdirSync(path.join(ROOT, d), { withFileTypes: true })) { const p = `${d}/${e.name}`; if (e.isDirectory()) walkDir(p); else if (p.endsWith('.css')) out.push(p); } };
walkDir('public/css');
return out;
};
test('no wait / progress cursor anywhere (the OS shows a spinning wheel next to the pointer — over the detail card beside an underframe button, the 交流 button …)', () => {
for (const f of cssFiles()) assert.doesNotMatch(read(f), /cursor:\s*(wait|progress)/, f);
});
test('a busy button keeps its icon and gets a delayed sweeping bar — no spinning ring', () => {
const v = Button({ loading: true, icon: 'play', children: '继续作战' });
assert.equal(v.props.disabled, true);
assert.equal(v.props['aria-busy'], 'true');
const nodes = [...walk(v)];
assert.ok(nodes.some((n) => hasClass(n, 'btn__icon')), 'the icon stays');
assert.ok(nodes.some((n) => hasClass(n, 'btn__busy')));
assert.ok(!nodes.some((n) => hasClass(n, 'btn__spin')));
const idle = [...walk(Button({ icon: 'play', children: 'x' }))];
assert.ok(!idle.some((n) => hasClass(n, 'btn__busy')));
const css = read('public/css/components.css');
assert.doesNotMatch(css, /\.btn__spin/);
assert.match(css, /\.btn__busy \{[^}]*animation: btn-busy-in 1ms linear 250ms forwards;/, 'only after 250 ms: quick replies show nothing');
assert.match(css, /@keyframes btn-busy \{ from \{ transform: translateX\(-100%\); \} to \{ transform: translateX\(250%\); \} \}/, 'a sweep, not a rotation');
});
test('机变: the card being picked shows 选择中 (no spinner) only until the pick shows in m.public', () => {
assert.equal(pickBusy(2, { idx: 2 }, null), true);
assert.equal(pickBusy(2, { idx: 1 }, null), false);
assert.equal(pickBusy(null, { idx: 2 }, null), false);
assert.equal(pickBusy(2, { idx: 2 }, 2), false, 'the pick landed: never stuck on the card');
assert.equal(pickBusy(0, { idx: 0 }, 0), false, 'index 0 too');
assert.equal(pickBusy(2, { idx: 2, takenBy: 'p_1' }, null), false);
const sp = { family: 'tactic', name: '战术决策', desc: '', cards: [{ idx: 0, name: 'A', desc: 'a' }, { idx: 1, name: 'B', desc: 'b' }], order: ['me'], turnPid: 'me', pickOf: new Map(), untimed: true };
const v = ChoiceOverlay({ pub: { players: [{ playerId: 'me', seat: 0, name: 'Me' }] }, sp, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
const nodes = [...walk(v)];
const busyCards = nodes.filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-busy'));
assert.equal(busyCards.length, 1);
assert.equal(textOf(nodes.find((n) => hasClass(n, 'spcard__busy'))), '选择中');
assert.ok(!nodes.some((n) => n.type === Spinner), 'no spinner over the card text');
// the pick landed (m.public) while the request still waits for its reply
const landed = ChoiceOverlay({ pub: { players: [] }, sp: { ...sp, pickOf: new Map([['me', 1]]), cards: [sp.cards[0], { ...sp.cards[1], takenBy: 'me' }] }, myId: 'me', solo: true, onPick() {}, busyIdx: 1 });
assert.ok(![...walk(landed)].some((n) => hasClass(n, 'spcard__busy')));
});
test('the "作战结束,等待队友完成作战" hourglass stands still', () => {
const css = read('public/css/screens/game.css');
assert.doesNotMatch(css, /hg-flip/);
assert.match(css, /\.chud__msg \.icon \{ width: \.22rem; height: \.22rem; color: var\(--gold\); \}/);
});
test('data store: a transient failure is retried (the file stays loading), a 404 / bad JSON is not; a superseded retry stays silent', async () => {
const waits = [];
const wait = async (ms) => { waits.push(ms); };
let calls = 0;
const flaky = createDataStore({
wait,
fetch: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
if (calls === 2) return { ok: false, status: 502, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ list: [{ id: 'x', v: 1 }] }) };
},
});
const p = flaky.load('chess');
assert.equal(flaky.status('chess'), 'loading');
await p;
assert.equal(flaky.status('chess'), 'ready');
assert.equal(flaky.lookup('chess', 'x').v, 1);
assert.deepEqual(waits, RETRY_DELAYS_MS.slice(0, 2));
assert.deepEqual(RETRY_DELAYS_MS, [600, 2000]);
const warn = console.warn;
console.warn = () => {};
try {
let n404 = 0;
const gone = createDataStore({ wait, fetch: async () => { n404++; return { ok: false, status: 404, json: async () => ({}) }; } });
await gone.load('bands');
assert.equal(gone.status('bands'), 'missing');
assert.equal(n404, 1, 'a 404 is final');
let nBad = 0;
const bad = createDataStore({ wait, fetch: async () => { nBad++; return { ok: true, status: 200, json: async () => JSON.parse('{"broken": ') }; } });
await bad.load('bands');
assert.equal(bad.status('bands'), 'missing');
assert.equal(nBad, 1, 'a delivered file that is not JSON is final');
let nDown = 0;
const down = createDataStore({ wait, fetch: async () => { nDown++; throw new TypeError('offline'); } });
await down.load('items');
assert.equal(down.status('items'), 'missing');
assert.equal(nDown, 1 + RETRY_DELAYS_MS.length, 'gives up after the retries');
// invalidated while waiting to retry: the old load stops, the new one decides
let release;
let nInv = 0;
const inv = createDataStore({
wait: () => new Promise((r) => { release = r; }),
fetch: async () => { nInv++; if (nInv === 1) throw new TypeError('offline'); return { ok: true, status: 200, json: async () => ({ a: { v: 2 } }) }; },
});
const seen = [];
inv.subscribe((name) => seen.push(`${name}:${inv.status(name)}`));
const first = inv.load('config');
await new Promise((r) => setImmediate(r));
const second = inv.invalidate('config');
await second;
release();
await first;
assert.equal(inv.status('config'), 'ready');
assert.equal(inv.get('config').a.v, 2);
assert.equal(nInv, 2, 'the superseded load did not fetch again');
assert.deepEqual(seen, ['config:loading', 'config:ready']);
} finally { console.warn = warn; }
});
test('every data file the in-match UI reads is in GAME_FILES (the match screen waits for them: no text appears late); main.js warms them in a room', () => {
const files = [
...readdirSync(path.join(ROOT, 'public/js/ui')).filter((f) => f.endsWith('.js')).map((f) => `public/js/ui/${f}`),
...['game', 'briefing', 'bandDraft', 'result'].map((s) => `public/js/screens/${s}.js`),
];
const used = new Set();
for (const f of files) for (const m of read(f).matchAll(/data\.(?:get|lookup|list|load|status)\('([A-Za-z-]+)'/g)) used.add(m[1]);
for (const m of read('public/js/ui/gameComponents.js').matchAll(/useData\('([A-Za-z-]+)'/g)) used.add(m[1]);
assert.ok(used.size >= 10, [...used].join(','));
const missing = [...used].filter((n) => !GAME_FILES.includes(n));
assert.deepEqual(missing, [], 'read by the match UI but not awaited by the match screen');
const main = read('public/js/main.js');
assert.match(main, /if \(s\.room && !prev\.room\) warmGameData\(\);/);
assert.match(main, /data\.loadAll\(GAME_FILES\)/);
});
});
test('GEO: the temp row the notice frames is the server\'s (row 8, cols 4–8)', () => {
assert.deepEqual([GEO.TEMP_ROW, GEO.TEMP_C0, GEO.TEMP_SIZE], [8, 4, 5]);
});
+1 -1
View File
@@ -202,7 +202,7 @@ describe('equipment dropped on an operator', () => {
};
const ctx = placementContext({ priv, stage: DATA.stages.act2autochess_m01, editable: true, getChess, getToken, getItem });
test('pickPieceAt picks the front-most sprite under the pointer', () => {
test('pickPieceAt: the rect whose centre is nearest (overlap → the nearer unit)', () => {
const rects = [
{ uid: 1, left: 100, right: 160, top: 400, bottom: 520 }, // row 9 (nearer the camera: lower on screen)
{ uid: 2, left: 100, right: 160, top: 350, bottom: 470 }, // row 10, behind
+3 -2
View File
@@ -16,8 +16,9 @@
// --rehearsal N layouts each bot rehearses per prep with the real sim (default: the Match default, 3; 0 = off,
// faster sweeps with the heuristic placement only)
// --check audit every match: engine invariants at every phase change + rule checks (server/match/audit.js:
// income, upgrade price, freeze, shop odds/bans, drafts, 联防 helpers, settle LP, final assault
// pairing/pool, hidden core, titles, deadlines). Exit code 1 when anything is violated.
// income, upgrade price, freeze, shop odds/bans, temp overflow lifetime (no piece outlives the prep it is
// due at), drafts, 联防 helpers, settle LP, final assault pairing/pool, hidden core, titles, deadlines).
// Exit code 1 when anything is violated.
// --errors per-source error summary over all runs: sim/content errors grouped by content module + handler label
// (+ unit) with occurrence and battle counts, meta handler errors by registry key, match engine errors
// --odds shop tier distribution of rolled chess slots per shop level (all runs)
+6 -2
View File
@@ -15,6 +15,8 @@
// enemy-pen demo (render-demo ?pen=<name>). Also writes public/dev/recordings/index.json.
//
// Usage: node tools/record-battle.mjs [--only name,name] [--out dir] [--seed n] [--max seconds]
// (`--max` caps every scenario; without it a scenario's own `max` — else 90 game s — applies, so a plain run
// reproduces the committed recordings byte for byte.)
import fs from 'node:fs';
import path from 'node:path';
@@ -39,6 +41,8 @@ const OUT = path.resolve(ROOT, typeof opt.out === 'string' ? opt.out : 'public/d
const ONLY = typeof opt.only === 'string' ? new Set(opt.only.split(',')) : null;
const SEED = Number(opt.seed ?? 20260927) >>> 0;
const MAX_SECONDS = Number(opt.max ?? 90);
/** Game seconds recorded at most for scenario `sc`: `--max`, else its own `max`, else 90. */
const maxSecondsOf = (sc) => (opt.max != null ? MAX_SECONDS : Number(sc.max ?? MAX_SECONDS));
const quiet = { warn() {}, error() {}, info() {}, debug() {} };
const ds = getDefaultSource();
@@ -111,7 +115,7 @@ const SCENARIOS = [
},
{
name: 'late-m01-r13', title: '常规作战 · 源石流发生装置 · R13(官方波次)', kind: 'normal', stageId: 'act2autochess_m01', modeId: 'mode_multi_hard', round: 13,
wave: 'official', waveSeed: 5,
wave: 'official', waveSeed: 5, max: 110, // the whole round (55 real s = 110 game s)
players: () => [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: lineup('act2autochess_m01', ['星熊*', '银灰*', '斯卡蒂*', '莫斯提马*', '白面鸮', '寒芒克洛丝*', '阿罗玛', '远牙*']) }],
},
{
@@ -169,7 +173,7 @@ function record(sc) {
const frames = [];
let field = null;
let ticks = 0;
const maxT = Math.min(MAX_SECONDS, sc.kind === 'boss' ? timeLimit : timeLimit + 1);
const maxT = Math.min(maxSecondsOf(sc), sc.kind === 'boss' ? timeLimit : timeLimit + 1);
while (!b.finished) {
b.step();
ticks++;