Files
ark-stronghold-protocol/docs/history/v2.3-playtest4.md
T

22 KiB
Raw Blame History

DESIGN §18 — User playtest #4 (v2.3)

Part of DESIGN.md (the index; section numbers are global).

18. User playtest #4 (v2.3): picking by tile, confirms, timers, battle state, content

Thirteen reports after playing v2.2. Where each is handled: #1 picking should simply follow the tiles and the dragged model moved away from the finger → §18.1; #2 a confirm step for 机变 picks → §18.2; #3 a countdown although playing alone → §18.2; #4 the strategy draft's 10 s turn out of step with the top-right countdown → §18.2; #5 the special 维式重锤 and 突变细胞 were sold in the shop → §18.6; #6 纯烬艾雅法拉's skill sound rang outside her skill → §18.4; #7 the operator card showed fixed numbers → §18.5; #8 no official element gauges → §18.3; #9 knocked-out operators vanished instead of lying on their tile with a redeploy ring → §18.3; #10 the enemy drones' behaviour → §18.6; #11 赫默's drone deployed at the start instead of by her skill → §18.6; #12 the 炎佑 dragon returned to a fixed point and its flame was no continuous jet → §18.6; #13 once in a while a huge piece of UI covered the screen for a second or two → §18.7.

18.1 Picking by tile, the model under the pointer (#1) — render/pick.js, render/app.js, render/drag.js

  • One rule: the ground tile under the pointer decides (render/projection.js pickTile, raised tops first). Prep presses / clicks / right-click / long-press / drag starts / hover (app.js pieceAt) and the enemy preview pen (penUnitAt) take the unit standing on that tile (board, bench row 7, temp row 8; pick.js pickOnTile). An empty tile selects nothing; a press on a unit's head drawn over the tile behind it is a press on that tile; a raised tile in front hides the near 0.17–0.24 tile of the one behind it, and a press there is on the raised tile — what is drawn. Several units on one tile (the pen's clusters, a piece held for a moment): the nearest to the pointer's point on the tile, ties → the front-most.
  • Battle (app.js battleUnitAt → pickBattle): an ally on the tile under the pointer — a knocked-out operator lying on its tile (§18.3) included. An enemy within ENEMY_REACH 0.6 tile [ASSUMED], using whichever is nearer: its ground position from the pointer's point on the ground, or its drawn body on screen (the upright line from its feet up to its head, in its px per tile) — so a press on a tall enemy's or the boss's torso or head picks it; a flying enemy uses its drawn body only. When an ally tile and an enemy both qualify the nearer (in tiles) wins; ties → the ally, then the front-most. Dead and dying units are not picked (a knocked-out operator waiting on its tile is).
  • Drag: the dragged unit is held under the pointer, mouse and touch alike — its drawn (lifted) feet DRAG_HOLD_TILES = 0.45 tile (in its own px per tile) below the pointer [ASSUMED: the v2.1 offset], so the pointer rests on the chibi; an item plate is centred on the pointer. The drop target, its highlight, tileHover and the direction wheel's tile are the tile under the pointer. An item dropped on a tile equips the unit on that tile (ui/gameLogic.js canPlace / dropIntent; an empty tile says 请将装备拖拽至干员身上). A release over DOM covering the canvas (the shop bar) is 'outside': the piece goes back.
  • Touch: the field canvas cancels touchend (app.js onTouchEnd, non-passive). A tap's compatibility mouse events and click came after touchend, hit-tested at the finger again — on the underframe the tap had just opened over the unit's tile (clamped under the top bar for rows 11–12 at 844×390), which pressed 撤退 / 出售. The canvas is also a click target of its own (a no-op click listener, app.js onTapTarget; 0.1.4): the browser's touch adjustment moves a tap onto a nearby element that responds to clicks (pointer listeners do not count) when the finger's contact area reaches one, and once the 收起 toggle of PR #149 (§10) pushed the bond discs one button to the right, a tap on the row-12 unit at 844×390 opened the bond popup. Holding the finger's point, the canvas wins: a tap on the board stays on its tile.
  • Notched phones: the shop bar keeps its place at the bottom edge instead of rising by the bottom safe-area inset (css/devices.css .gm__hud > .shopbar): raised by it the bar covered ~3/4 of bench pads 2–9 at 844×390 with the iPhone insets, and bench pieces are pressed and dropped on by their tile. (The v2.3 figures — 23 % of those tiles free before, 71 % after — are superseded by §19.8: the prep camera now clears the HUD, every bench pad 100 % free on phones.)
  • Removed: the pixel probe (drawnAt, hoverProbeAt), body shapes (pickShape, PROBE_MARGIN, BODY_H), TOUCH_LIFT_TILES, the drag dropPoint hook, pieceDragOver, ui/gameLogic.js pickPieceAt / equipRetarget. UnitView.bounds() is a plain body rectangle again (tooltips and pieceScreenRect only).
  • Tests: test/render/pick.test.js, drag.test.js, unitview.test.js, test/ui/gameLogic.test.js, test/ui/playtest4.test.js (shop bar); browser: test/render/models.browser.test.js (every tile of every board, high ground, bench and temp; battle allies and enemies incl. torso and head; the model under the pointer at every drag step; the wheel on the pointer's tile; equipment by tile; phone touch incl. the ghost click and the shop-bar release), test/render/downelem.browser.test.js (a knocked-out operator picked on its tile), test/ui/playtest2.e2e.test.js (the top-row tile under the bond strip; the notched phone's bench).

18.2 Confirms and timers (#2, #3, #4) — ui/choiceOverlay.js, server/match/Match.js, screens/bandDraft.js

  • 机变 cards take two taps (like the shop's cards): the stateful ChoiceOverlay wraps the pure ChoiceView. A first tap on a pickable card (bounty = extra enemies, 道具补给 / 机密商店 items, tactics) only selects it — gold frame, a 确认选择 · 再次点击 strip, the other cards dimmed, 确认选择 in the header; a second tap on it, or 确认选择, sends g.choice. Another card moves the selection; Esc or a press elsewhere drops it; so do the card being taken, the turn moving on and a pick in flight. Cards stay buttons (Tab / Enter). Each card shows its full effect text (§20.7). The other picks were already two-step (晋升奖励 in the shop bar, the strategy draft's 确认选择, 确认替换).
  • A single human is untimed (Match.loneHuman: exactly one human seat at the start → Match.soloUntimed): 独立模拟 and a 同盟 room started alone or with AI teammates only publish no INFO_CHECK / BAND_DRAFT / SP_DRAFT / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run silently — the timers only ever made humans wait on each other. The co-op rules stay (shuffled draft order and skip, 6 机变 cards, 联防); pause stays solo-only; a disconnected lone human's match waits (the co-op reconnect window then ends it as abandoned). Countdown (ui/components.js) renders nothing without a deadline (it used to draw a -- COUNTDOWN placeholder that only a CSS rule hid).
  • The strategy draft has ONE countdown: every turn lasts Match.BAND_TURN_SECONDS = 30 s [ASSUMED] (× timerScale; data/config.json timers.bandTurn says the same — the official data only has the whole BAND_CHECK step, 50 s, now informational) and m.public.deadline IS the current turn's end (= draft.turnDeadline; draft.turnSeconds = the turn length, 0 untimed), so the step header and the picker's row show the same number. No step cap: the turns bound the step (≤ (seats + skips) × 30 s). AI seats pick at once. The draft screen reports its highlighted strategy (g.bandFocus); a turn that runs out takes it while it is allowed and free (Match.timeoutBand), else 华法琳 (band_bldsk), else the first free strategy (defaultBand, 队友已选 never). The selection starts on that default and the tip always names what a timeout gives ("每位博士有 30 秒,超时将自动选择当前选中的「X」"; untimed: "本局不限时"). server/match/audit.js checks it (deadline = the turn's, 30 s; nothing timed for a single human).
  • Tests: test/ui/playtest4.test.js, test/ui/bandDraft.test.js, test/match/draft.test.js, test/ui/gameLogic.test.js (phaseTotalSeconds = the turn), test/match/hardening.test.js (the audit), test/ui/mock.e2e.test.js (two taps). Browser tests that need a timed co-op briefing now seat two humans (test/ui/loadout.e2e.test.js); a single human's 机变 is picked by the test itself (test/ui/playtest2.real.e2e.test.js), and the real-server drafts wait for BAND_DRAFT before picking (a client left in the briefing let its 30 s turns run out).

18.3 Knocked-out operators and element gauges (#9, #8) — sim/Battle.js, sim/damage.js, render/interp.js, render/units.js

  • Knocked out, not gone: Battle.snapshot() lists down: [[id, respawnAt, respawnTime, state, row, col]] (§8.2; the tile since §21.24) for operators knocked out (reason 'killed', or FORCED_EXIT, entering 联防 knocked out, §19.3) or forced out by their own effects (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3, a 商人 that cannot pay — every 退场 but the 突袭 retreat, §22.15), deployed at least once and with a finite respawn timer; state = sim/constants.js DOWN_STATE (0 counting, 1 timer done but DP < cost, 2 timer done but its tile is taken — the official auto-redeploy rule, research 01 §4.3; a safeguard since §21.24, no ally may take the tile a knocked-out operator lies on). Battle.isDown(u); fieldMeta() lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (interp.js downAt, app.js syncBattle, units.js setDown) keeps such an operator's view on the tile it lies on (the entry's row, col): its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a redeploy ring above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The deploy event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
  • Element gauges, official rules (PRTS 元素 / 术语释义; damage.js): gain × elemTakenMul × (1 − 损伤抵抗 / 100), 损伤抵抗 = data epResistance (EP_RESISTANCE; it used to read epDamageResistance as a fraction). A burst's 爆发冷却 (= its duration: 灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s; the operators' 侵蚀 had none) locks EVERY element of the unit — nothing fills or recovers (reduceElement) — and its end resets EVERY gauge (burstLocked(unit) takes no element). 神经 applies the stun (operators) / 3 麻痹 (enemies) before its damage, 侵蚀 its DEF cut before its damage; the operators' 凋亡 burst holds 阻回 (noSp: skills.js gainSp refuses SP of every kind — time, attack, hurt, granted) + 静默, −1 SP/s and 100 arts/s.
  • The gauge on screen (official display: one icon, the "当前损伤元素"): damage.js elementView(u, now) → b.snap elem (§8.2): the fullest gauge, ties by the official element id (constants.js ELEMENT_ORDER: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. interp.js appends it to the unit's tuple; units.js draws it — superseded by §20.8: a row under the unit's own HP / SP bars, the element disc (0.15 tile, 8–15 px; operators with the glyph, enemies ×0.8 plain colour) and a white bar of the remaining 元素值 (1 − fill), refilling in the element's colour with a pulsing disc during the 爆发冷却 (v2.3 drew a white ring right of the bars; the procedural atlas textures.js hudRings keeps the discs and the redeploy ring), sprites made on first use and hidden when idle. The burst's lock status (burnBurst, neuralBurst …) is not repeated in the status row while the element icon shows it (only a feed without elem, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and look (§20.8).
  • Recordings (tools/record-battle.mjs) carry both keys, so the render demo shows them. Tests: test/sim/downelem.test.js, test/sim/combat.test.js, test/render/downring.test.js, test/render/interp.test.js, test/render/downelem.browser.test.js.

18.4 Battle sounds (#6) — public/js/audio.js, tools/assets/audio.mjs

  • Cause: every 'atk' — heals included — made its source the target's "last attacker", and any later damage on that target (enemy projectiles, element fills, DoTs) played that unit's hit; 纯烬艾雅法拉 (who only heals) had her S3 impact (p_imp_gtshpbrnch_s, bank ON_ABILITY_HIT.attack.2) as hit in the manifest, so it rang all the time.
  • Now only a hostile 'atk' (from the other side) authors the target's next impact: it plays once, within 2.5 s real [ASSUMED], and only for phys / arts / true damage; heals and chain bounces never author one. An operator's skill-mode file (official names _d / _h / _s; the normal attack's end in _n) never plays for a normal attack (normalAttackSfx). At most 2 overlapping copies of one battle sound (SfxLimiter; official banks maxSoundAllowed 2 — a third copy is skipped, not the oldest stopped [ASSUMED]).
  • Manifest: tools/assets/audio.mjs pickUnitSfx(banks, { operator, projectile }) picks normal-mode banks for operators — the plain attack / combat ability first, never a bank with a skill-mode file — with the operator's own projectile banks (ON_PROJECTILE_BORN / _HIT.projectile_chr_<name>, tools/assets/plan.mjs) as fallbacks; enemies and tokens are unchanged. 56 operators' picks changed (e.g. agoat2 hit → p_imp_gtshpbrnch_n, nearl2 attack → p_atk_goldspear_n, whitw2 → p_atk_whtolfdrksl_n). Loop-bank skill sounds still play once (open).
  • Tests: test/ui/audio.test.js, test/assets.test.js.

18.5 Live operator stats (#7) — ui/detailPanel.js, battle/runner.js, Match.unitStats

  • Battle: battleRunner.unitStats(id, fieldId) reads the local sim's last computed stats (unit._s, never the lazy unit.s, so looking cannot shift the sim) and the detail card re-reads it 4× a second: current HP / max HP, ATK, DEF, RES, attack interval, block (enemies also move speed) and the range grid (range: the live 攻击范围 — a running skill's grid included; 全场 for a whole-field range, smaller cells beyond the box, §21.16). battleRunner.unitIdOf(uid, ownerId, fieldId?) maps an own board piece to its battle unit, so a card opened in prep and left open into the battle turns live.
  • Prep: C→S g.unitStats {seq?} → m.unitStats {seq, round, units} (§8.2): Match.unitStats builds a Battle from the player's battle input after the onBattleStart meta (dispatched with ev.preview: true and no spawns — handlers must not change the match then, docs/META.md), with the flags of the battle it previews (a normal round gains IN_BATTLE layers from its start; the Final Assault is previewed as a normal field without them), starts it (initial deployment + battleStart hooks), reads it and drops it — never stepped, so skills and timed effects are not in it [ASSUMED display] (a passive range skill and a permanent 攻击距离 are part of the start range, §21.16); cached per input (≈ 0.3–0.7 ms a build). screens/game.js asks 120 ms after any m.private / phase change while an own board unit's card is open and keeps the newest answer only.
  • The card colours each value against the unit's base — green when it helps (a shorter interval is up), red when it hurts — with the difference beside it, the base in the tooltip and a 实时 / 开战时 tag. The shape is shared/protocol.js unitStatsEntry. Tests: test/match/unitStats.test.js, test/match/runner.test.js, test/ui/playtest4.test.js.

18.6 Shop-only items, drones, summons, 炎佑 (#5, #10, #11, #12) — tools/build-data.mjs, sim/simdata.js, sim/content/*

  • #5 not sold in the shop: the official trapShopChessDatas has hideInShop false for all 56 normal items, so an explicit sourced list (tools/build-data.mjs SHOP_EXCLUDED_ITEMS → items.json shopExcluded / shopExcludedBy) marks the effect-only items: 战栗 / 坚固 / 加速 / 灼燃维式重锤 (the 维多利亚 bond every 25 layers, 洛洛's 定制品) and 突变细胞 (the 昆图斯 strategy 不稳定要素; user playtest #4 first-hand). sim/simdata.js isShopItem is the one predicate for every shop-item draw — the shop's item slot, 道具补给 / 机密商店 cards, pool_equip_normal / _shop_1 / _kathe / _narant, Match.rollItemId — so 51 normal items are sold (the plain 维式重锤 still is). 洛洛's 定制品 (pool_equip_rockr) = the same 4 special hammers as pool_equip_vict, uniform [ASSUMED]. The item card says 调度中心不出售 · 获取途径:… (detailPanel.js ItemDetail). Other strategy-granted items stay on sale (no source says otherwise; 变形同构体 / 骑士储蓄罐 / 盟约之币 appear in an official 机密商店 screenshot).
  • #10 drones (stats and ranges = enemy_database level 0, re-derived by test/data.test.js; behaviour per PRTS): 暴鸰 makes no normal attack — it holds still and drops one bomb (released 8 ticks into its Attack clip, homing at 5 tiles/s) that hits its target and the 8 tiles around on arrival (100 % ATK, splash ignores camouflage); a stun before the release interrupts it; when the cast ends (landed, ≥ 0.667 s after the release) it flies on at ×2 speed (§21.14); 帝国炮火先兆者 / 中枢先兆者 fire a shell per attack that lands 3 s later at the target's spot and hits every ally within 1.2 for 100 % of the ATK at launch, with no source (content/enemies.js kitShell; new ai.js enemyAttack hooks profile.deferHit — content resolves the hit in its attack handler — and profile.shot, the 'atk' kind: 'mortar' draws no projectile; fx bombardShell); 假想敌:黑云 grabs within range_radius × 2.5 (3.75) at most 3 flyers (nearest first [ASSUMED], 0.5 s delay [ASSUMED]), both held 4 s, ammo capped by enemies.json sp.maxSp (3; new sp from enemy_database spData), 全弹发射 = one 130 % hit per ammo on random allies anywhere on the field; “萨科塔之眼” never attacks flying allies; 萨科塔 self-fear speed final ×1.5, while it flutters inside its tile (§20.4). Unchanged (already right): 妖怪 / 妖怪MKII / 威龙 / 寒霜 / 法术大师A1·A2 / 御4 / 护障 / 护障·P / 远眺 (威龙's drawn size was fixed in §20.4).
  • #11 skill summons are not prep pieces — superseded by §20.1: v2.3 limited tokens.json placeable to tokenDisplayType DEFAULT summons made by a talent (海嗣, 狼群, 流形), so 赫默's 医疗探机 and 巫恋's 诅咒娃娃 appeared only when the skill fired. Since user playtest #6 every manually deployable summon is a hand piece again, and its placed piece deploys once at the battle start (PRTS), then with each cast.
  • #12 炎佑 (PRTS “炎佑” 级别0(卫戍协议), content/tokens.js yanyouKit; 孤立 — never healed or selected by allies, §20.3): it flies after the highest-aggro targetable enemy of the whole field (operator order) and hovers 0.25 tile [ASSUMED] from it (a second 炎佑 of 9 炎 0.8 row beside the first); with no enemy on the field it stays where it is (user playtest #4; PRTS says it then tracks a random tile). 祛恶之焰 (模式乙): fires like a normal attack when its 15 s cooldown is full, locks that attack's first target and channels up to 20 s — each second (the first at once) 60 % ATK arts to every enemy within 1.0 of the target, no normal attacks meanwhile — ending when the target is gone or 炎佑 is silenced (inferred); the cooldown then runs again [ASSUMED]. Every damage it deals adds burn = 20 % ATK (flame ticks included, official 下半 notice); 元素脆弱 aura of radius 1.5 around itself; it ignores element damage; ATK / HP = the template 600 / 12000 plus 30 % of the 炎 operators' sums (PRTS 最终加算; the 9-炎 ×1.5 on the whole ATK [ASSUMED]). The flame is drawn as one continuous jet (render/fx.js _flame): the sim emits fx yanyouFlame {x, y, id: dragon, target, r, n, dur: 1} once per game second, and each tick re-aims and extends the dragon's jet — a stream of fire from the dragon onto its locked target with a burning disc of radius r following the target — which burns dur + 0.2 s and goes out when no tick comes or the dragon is gone.
  • Tests: test/content/playtest4_summons.test.js, enemies_bosses.test.js, tokens_devices.test.js, bonds_core.test.js, choices.test.js, test/data.test.js, test/match/pool.test.js, test/render/fxflame.test.js, test/ui/playtest4.test.js (item card).

18.7 The occasional giant UI flash (#13) — render/fx.js pop

The first in-battle layer gain of each bond (b.ev 'layer') pops that bond's icon over the field (FxSystem.pop). The icon is PIXI.Texture.from(url) — a 1×1 placeholder until its image has loaded — and the pop sized the sprite from it (46 / 1): the bond icon was drawn ~5000 px across the whole screen for the pop's 1.4 s, once per bond per page (73 official garrisons gain layers in battle, e.g. 送葬人's). Now it is sized (46 px along its longer side) only once its texture is valid, hidden until then. Found by sampling long real-server sessions every 80 ms (DOM boxes > 25 % of the viewport, images drawn > 300 px, Pixi objects > 25 % or showing a whole atlas page, CSS scale ≥ 2); every other large object seen is a designed full-screen layer. Tests: test/render/fxpop.test.js, test/render/flash.browser.test.js.