diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c610af4..6035d726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,12 @@ - 歌蕾蒂娅等钩索师、推击手按官方特性「可以放置于远程位」,可以部署到高台上(GitHub #32)。 - 敌人的溅射、爆炸、范围技能、区域和光环打不到没有阻挡它们的隐匿干员;迷彩干员照常被溅射打中(GitHub #32)。 - 号角、灰毫(含精锐)按官方不能攻击空中单位,也不再溅射到它们(GitHub #35、PR #48)。 +- 史尔特尔「余烬」、耀骑士临光二技能等强制退场的干员,和被击倒一样在原地留下倒地干员、显示再部署倒计时,再部署也回到这一格(按 PRTS 卫戍协议帮助;GitHub #60)。 - 莫斯提马的减速(天赋 −15%,三技能期间 ×3)在敌人身上显示减速图标;减速本身一直有效(GitHub #16、PR #48)。 ### 敌人 +- 没被阻挡的远程敌人每次攻击都会站定、播完攻击动作再继续前进,不再边打边滑(停顿取自各自攻击动画的长度;GitHub #58)。 - 隐匿的敌人被阻挡后现形,阻挡解除 3 秒后才重新隐匿(官方机制,个别敌人按官方是 0 秒或 1 秒),以前下一帧就隐去;被阻挡时被击倒的深池逐火战士,「余烬」在 1 秒重生结束后还会现形约 2 秒,远程干员和干员的溅射能补刀;没被阻挡时被击倒的余烬会立刻隐匿,和官方一样需要阻挡或反隐(GitHub #43)。 - 活性源石的效果在敌人离开格子后继续保留 300 秒(重生后清除);沼泽每秒结算一次,重量 ≥ 3 的单位一次叠 2 层;深水区的伤害是无来源的真实持续伤害,不是环境伤害(GitHub #33)。 @@ -27,6 +29,11 @@ - 阿戈尔的吞噬跳过已经被它击倒的成员:不再对他们结算,复活的成员保持站立(GitHub #33)。 +### 界面 + +- 干员调配界面新增「局内数值」:按所选技能和模组显示生命、攻击、防御、法抗、攻击间隔、阻挡、费用、再部署、攻击范围以及特性和天赋,可切换精锐 / 普通(GitHub #64)。 +- 干员详情卡的特性在选了非默认技能或模组时也显示该模组的特性,以前会退回职业特性。 + ### 素材与工具 - 没有提取本机客户端素材时,setup 也会从公开镜像下载战斗表情和「玩法说明」页面(约 21 MB),不再缺图;部署文档按实际情况订正(GitHub #42)。 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7a573e5..dc98e29a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -266,11 +266,11 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama ### 5.5 Combat rules (engine) - **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too). -- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. +- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. - **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7). - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18). - **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3; 史尔特尔 during 余烬, §22.7), and never the enemies they block — also while blocking (§21.17); do nothing otherwise (SP still regenerates). -- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1), and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4). +- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1), and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); unblocked, they stand for each attack's clip (enemies.json `attackAnim`: its wind-up before the strike and the rest of the clip after it, shortened when the attacks come quicker than the clip; `ATTACK_PAUSE = 0.35 s` after the strike when no clip is known; a 「不停止移动」 attacker never stops; §22.14). FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4). - **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, a drone's death; the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3). - **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle. - **Results**: `damageDealt` / a unit's `dmg` count only HP removed from the other side — a unit's own drain or 流失 and friendly damage are the target's `taken` only (§21.12). @@ -658,7 +658,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si ### 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'`, not withdrawn — or `FORCED_EXIT`, entering 联防 knocked out, §19.3), 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. +- **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`. @@ -1365,7 +1365,7 @@ Still [ASSUMED] (each in its section): - §21.21: a 突袭 retreat + redeploy is a deployment (it re-arms the lock); an in-place 复活 (M3茧甲, 埃芒加德) is a new deployment too (PRTS's 复活 is 退场 + a 0-time / 0-cost redeploy); the 阿戈尔 devour and the carrier's own 源石溶剂 spend the lock; kits' own savers before the item's 不死; M3茧甲 before 埃芒加德; a running window ends with its deployment and outlasts the lend that started it; a free pick or grant is no 购买; a 谢拉格 already priced ≤ 1 still counts as the round's first. - §21.22: a ground enemy's shot in flight when 蒂比 takes off lands on nothing; ground-enemy auras still apply; 流失 DoTs keep ticking; an area skill cast because allies are near counts only those it can hurt (卢西恩's 【aoe】, 锏's CircleAttack). - §21.23: air blocking (blockFly) on a fenced tile stays; the gate reads ground passability. -- §21.24: the body goes home once, at the knock-out, only when its home is free; 初始位置 = the homes of the board pieces of every player on the field, on the field or not (a summon that has expired or been killed included); 歌利亚头盔 ignores bodies; a withdrawn operator leaves no body. +- §21.24: the body goes home once, at the knock-out, only when its home is free; 初始位置 = the homes of the board pieces of every player on the field, on the field or not (a summon that has expired or been killed included); 歌利亚头盔 ignores bodies; every 退场 leaves a body (§22.15), except the 突袭 retreat, which redeploys at once. Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流失 can kill an ally (§21.17); 伊利昂的木驮兽's `Full` skin (§21.14); the render-demo recording `public/dev/recordings/normal-a1m04.json` still shows 战场#04's old L-shaped lower route (display only); M3茧甲's "同名装备的效果不可叠加" (two copies give two revives, a + b three) and two own 坚固维式重锤 giving a 16 s lock; the M3茧甲 / 埃芒加德 revive in PRTS's form (退场, then a 0-time / 0-cost redeploy: the SP reset and the other per-deployment effects — only 坚固维式重锤's lock treats the in-place revive as a new deployment) and a status icon for the 不死 window (§21.21); 深巡 S2's nearest-first order (§21.23); 【盲信之誓】 hurting once per chain (PRTS "多条锁链的伤害不叠加") and the “帝国的甲胄” barrage's timing (§21.22's audit). @@ -1402,7 +1402,7 @@ Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流 - **Seen**: "突袭盟约的干员会移动到濒死干员的尸体上". **Real**: a knocked-out operator left the occupancy map at once and only its home tile stayed reserved — a 突袭 member that fell after its jump lay on its landing tile, which the next jump picked; a timed redeploy came back on its home, where a teammate that had jumped there lay (96 real battles: 6 placements on a body; the shared boss field mirrored: 38). - **Official** (PRTS 卫戍协议/帮助 §作战阶段 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"; "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"; "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置". The 突袭 text "再部署至一名地面敌人周围" adds no tile rule. -- **Now**: a knocked-out operator lies on `body` — where it fell, or its own home when it fell on another board piece's home and its home is free (x / y / tile keep where it fell for the 被击倒时 handlers). Every redeploy comes back there (timed, 不屈, 阿戈尔); a withdrawn operator still comes back home. `downOn`: `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse the body tile for every ally; `isReservedTile` reports it, so the 突袭 landing, tactical points, summon / device pickers and 维娜's lions skip it. b.snap `down` carries the tile (§8.2) and the client draws the body there; WAIT_TILE matches the `_checkRedeploys` hold-back and is a safeguard. After the fix: 0 placements on a body in 526 jumps. **Superseded**: "later redeploys use the board tile" — an operator moved off its board tile (a 突袭 jump, 乌尔比安's anchor) now redeploys where it fell; research 01 §4.3 carries the PRTS quotes. +- **Now**: a knocked-out operator lies on `body` — where it fell, or its own home when it fell on another board piece's home and its home is free (x / y / tile keep where it fell for the 被击倒时 handlers). Every redeploy comes back there (timed, 不屈, 阿戈尔), also for an operator forced out by its own effects (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3, a 商人 that cannot pay; §22.15); only the 突袭 retreat leaves no body. `downOn`: `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse the body tile for every ally; `isReservedTile` reports it, so the 突袭 landing, tactical points, summon / device pickers and 维娜's lions skip it. b.snap `down` carries the tile (§8.2) and the client draws the body there; WAIT_TILE matches the `_checkRedeploys` hold-back and is a safeguard. After the fix: 0 placements on a body in 526 jumps. **Superseded**: "later redeploys use the board tile" — an operator moved off its board tile (a 突袭 jump, 乌尔比安's anchor) now redeploys where it fell; research 01 §4.3 carries the PRTS quotes. - **QA after the integration, fixed**: rule 3 counted only the board pieces that were not removed, but a summon leaves its home free only once it has expired or been killed (a docked one keeps its tile reserved), so the 召唤物 clause never applied — a 突袭 member that landed on the home of 浊心斯卡蒂's expired 海嗣 and fell there lay on it and kept the 海嗣 (远古血亲) from coming back. Every board piece's home counts now, on the field or not. Cost: the body bookkeeping added about 2.5 % per tick on 519 real specs (8 % on boss fields; whole matches and the perf budgets unchanged); `_checkRedeploys` now checks the DP before the tile scans, so an operator waiting for DP costs no ally scan — on the same 519 specs, against the integration head, the same tick counts and boss fields 11.9 → 10.9 µs per tick (a tile → body map is left for later). - [ASSUMED]: §21.20. Tests: `test/sim/feedback1f-downed-tile.test.js` (rule 3 for a summon that has expired or been killed), `test/render/feedback1f-down-tile.test.js`, opt-in `test/render/feedback1f-down-tile.browser.test.js`; `downelem`, `bonds_addon`, `engine-requests`, `robustness` follow the rule. @@ -1470,7 +1470,7 @@ Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged ag - **Now**: - `_wantsBack()`: facing UP with a Back model, while the unit stands; a dead or knocked-out one keeps the Back model only when that skeleton has a Die clip (`dieClipDur` of its manifest entry), so the other 131 fall and lie with the Front model (unmirrored, as facing UP). - `die()` and `revive()` mark the view, and its next `update()` — after every event and the snapshot of that frame (render/app.js) — runs `_syncModel()`: the model the state wants is loaded when it is neither shown nor loading, and swapped in place like a direction change (no avatar diamond in between). A death and a redeploy handed out in the same frame load nothing: a 突袭 jump (retreat + redeploy in one tick), a 不屈 revive (from the death hook), a backlog delivered after a hidden tab. - - The Front model replays the state: the Die clip where it would be (`dieT`), a knocked-down one holding its end; `die(true)` and a view built for a unit already down start on the held end (the 联防 entry `FORCED_EXIT`, a field entered late, the re-entry from the field meta after an overflowed hidden-tab backlog). Its first frame gets the knocked-down grey (`_dropActor` drops the cached tint; a swapped-in model used to keep the previous model's tint state). A withdrawn operator facing UP (no `down` entry) fades like one facing RIGHT: the Front model's Die clip times the fade (`_fallDur`). + - The Front model replays the state: the Die clip where it would be (`dieT`), a knocked-down one holding its end; `die(true)` and a view built for a unit already down start on the held end (the 联防 entry `FORCED_EXIT`, a field entered late, the re-entry from the field meta after an overflowed hidden-tab backlog). Its first frame gets the knocked-down grey (`_dropActor` drops the cached tint; a swapped-in model used to keep the previous model's tint state). A view that gets no `down` entry (summons and devices) fades like one facing RIGHT — a forced-out operator facing UP lies down like a knocked-out one (§22.15): the Front model's Die clip times the fade (`_fallDur`). - The redeploy (a `deploy` event: timed, 不屈, 阿戈尔 — every `Battle._deploy` — or a revive seen only in the snapshot) brings the Back model back; a deploy clip the Front model is playing goes on on the Back model where it is (`SpineActor.deployElapsed`). - Safety net: until the Front model is there, or when it fails to load, the Back model holds still — `SpineActor.die()` on a skeleton without a Die clip stops whatever looped and holds the first frame of its idle clip (frozen when it has none either), and nothing restarts an attack on a dead actor. The 20 summon / enemy skeletons without a Die clip (e.g. 浊心斯卡蒂's 海嗣) now fade holding still instead of looping [ASSUMED look]. - Unchanged model choice: operators facing RIGHT / DOWN / LEFT, enemies and summons (no Back models in the manifest: `_wantsBack` is false for them; only the safety net above changes how their idle-only skeletons die), the redeploy ring, the sim. Nothing changes in render/interp.js or battle/runner.js: `die` / `deploy` are state events every client stage keeps (runner `keepsState`, game.js `keepEarly`, interp `isCosmeticEvent`), and b.snap `down` / `fieldMeta` rebuild the view of an operator already down; both now reach the model swap. @@ -1645,3 +1645,46 @@ Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged ag - **Players notice**: 号角 and 灰毫 (and their elites) ignore flying enemies completely — no shot, no splash — so flyers need another operator; against ground enemies they play as in 0.1.1. Enemies slowed by 莫斯提马 carry the slow icon. - Contributed by the PR #48 author (Paper-Yuan), squash-merged 2026-10-04. Normative lines rewritten with it: docs/SIM.md (the profession table: 要塞 ground only, by the PR), docs/DATA.md §2.1 (`canHitFly`). - Tests: the PR's — `test/sim/professions.test.js` (the four records read `canHitFly` false / `groundOnly`; a 要塞 beside a FLY enemy makes no attack and leaves it untouched, beside a ground enemy it attacks and splashes) and `test/content/kits_t4.test.js` (莫斯提马's slow buff carries the `slow` status). + +### 22.14 Ranged enemies stand for each attack (#58) — `sim/ai.js attackStand / enemyAttack / updateEnemy / advanceRoute`, `sim/simdata.js normalizeEnemy`, `sim/Battle.js spawnEnemy`, `tools/build-data.mjs enemyAttackAnim / attacksOnTheMove / loadManifest`, enemies.json `attackAnim` / `attackMoves`, `render/spine.js SpineActor.windUp / attack (clipPerAttack)`, `render/units.js` + +- **Seen**: 「1.1中敌方远程攻击单位在攻击时任会移动」. **Real** in the sim and the client: an unblocked ranged enemy stood only `ATTACK_PAUSE` = 0.35 s after each strike (no source for the number), while every ranged attacker's attack clip is longer (median 1.33 s, 0.37–3.5 s); the client stretched the clip over the whole attack interval and kept the attack pose for 1.4 intervals, so the clip looped while the enemy walked about 90 % of the time. Measured on feedback2 6b634fe (能天使 on (10,6), one enemy walking row 9): 隐形弩手 (interval 2.70 s, clip 1.0 s, strike frame 0.533 s) walked 0.24 tiles during the wind-up, stood 0.37 s and walked 2.33 s per attack; 深池暗影术师 (3.0 s, 1.2 s) stood 0.37 s and walked 2.67 s. +- **Official**: no source gives the length of the stop. The owner's decision (2026-10-04, from first-hand memory of the official game): a ranged enemy stops for each attack's animation and walks on between attacks. The enemy handbook treats attacking on the move as a special ability — “十字路口”量产型 / 试做型 「不停止移动的四向攻击」 (not an enemy of this mode). +- **Cause**: `ai.js enemyAttack` set `pauseUntil = now + ATTACK_PAUSE` after the strike and nothing before it; the data had no clip lengths (they lived only in the client's asset manifest); `SpineActor.attack` / `windUp` played the clip at `clip / interval` speed in a loop. +- **Now**: + - Data: `tools/build-data.mjs enemyAttackAnim` copies each enemy model's attack clip from the committed asset manifest (data/assets.json `anims.attack.loop` — the clip the client plays, not an Idle stand-in —, its length and first `hits` time) into enemies.json `attackAnim` { clip, dur, hit } (213 of 249 keys; the rest have no attack clip). An ability text with 「不停止移动」 gives `attackMoves: true` (no key of the mode). The sim reads both from its data, never from client files. + - Sim (`ai.js attackStand`): the stand is the clip — `hit` of it before the strike and `dur − hit` after it, both divided by `dur / interval` when the attacks come quicker than the clip. Before the strike: `enemyAttack` reports the wind-up (cooldown ≤ the wind-up, an ally it may target within its reach) and `updateEnemy` holds the enemy; after the strike `e.atkStandUntil` holds it. Only the walking is held (`advanceRoute(…, standing)` holds a MOVE leg, and a 诱导 walk): a checkpoint's WAIT keeps running and DISAPPEAR / APPEAR legs still happen — leaving the field ends the stand — so a wait is not lengthened by the clips (data/waves.json has 122 WAIT and 99 DISAPPEAR checkpoints; review finding). It is drawn standing (`moving` false, so the client's base clip is Idle until it walks). The first attack of an enemy that walks into range with its cooldown long over still lands at once (no wind-up to stand through; strike times are unchanged). No clip known: `ATTACK_PAUSE` after the strike, as before; `attackMoves` (data, or content through `e.profile.attackMoves`): never stops. A stun / freeze / sleep ends the stand, and a 恐惧 is not held by it. Blocked and melee enemies are unaffected; content holds are separate (暴鸰 has no normal attack — `noAttack` — and hovers through its drop on its own `pauseUntil`). + - Client (`SpineActor.clipPerAttack`, set for every enemy model by `render/units.js`): each attack plays the clip once, wound up so its strike frame meets the attack, at its own speed (faster only when the attack interval is shorter than the clip), then the resting clip — Move once the sim walks it again. An attack seen late starts on its strike frame. Operators keep the clip looping over their attack rhythm. +- **Measured**: + - Same layout as above (`attackAnim` in, this branch): 隐形弩手 stands 1.00 s per attack (0.50 s before the strike — one tick short of 0.533 — and 0.47 s after) and walks 1.70 s; 深池暗影术师 stands 1.23 s and walks 1.80 s; 萨卡兹百夫长 (5.0 s, clip 3.5 s) stands 3.53 s and walks 1.50 s (it used to stand 0.40 s). Over the 75 ranged attackers of the mode, the median share of an attack cycle spent walking went from 91 % to 63 %; three (clip ≥ interval: 深溟巢涌者, 富营养的巢涌者, 节日爵士乐手) no longer walk while they have a target. + - Real rounds: four seeded 4-bot co-op matches (HARD seeds 1–2, ABYSS seeds 1–2, rounds 1–12 / 14) saved every normal field as its BattleSpec. The 58 fields with ranged enemies were replayed on 50c9b5b and on this branch. Leaks 750 → 764 (+1.9 %), target LP 753 → 767, kills 886 → 876. The 423 ranged enemies: leaked 222 → 223, mean time on the field 24.6 → 27.2 s (+11 %), their attacks 2725 → 2948 (+8 %), mean tiles walked 6.67 → 6.65. Per field the change goes both ways (HARD s1 R9 one field 0 → 6 leaks, ABYSS s1 R9 another 14 → 8). The same numbers after the route-leg fix (none of these fights has a ranged enemy on a WAIT / DISAPPEAR leg while it attacks). The ranged enemies cross the field more slowly, but they also stand in range of the operators longer and hit them more often, so the net effect on leaks is small. +- [ASSUMED]: the stand lasts exactly the attack clip and is split at its strike frame; the clip's speed-up when the attack interval is shorter than the clip (the client's rule); no wind-up stand before an attack that lands as the target comes into range; a stun / freeze / sleep ends the stand, and 恐惧 is not held by it; an enemy in another form (掠海漂移体's crawl, 转译基底's forms, 杰斯顿's second form) stands for its base clip — known mismatches: 扎罗's second form stands 1.433 s (A_Attack) though its B_Attack lasts 2.0 s (2.5 s interval: about 0.4 s of walking during the recovery), and 转译基底's 特战术师 stands 2.0 s (B_Attack) though its D_Attack lasts 1.667 s; a per-form clip would need the client's FORMS mapping in shared data (later); an attack interrupted by 麻痹 keeps the old 0.35 s stand (PRTS 异常效果 麻痹: a 0.5 s 麻痹震颤 that also stops movement — not modelled); a model without an attack clip keeps `ATTACK_PAUSE`. +- Normative lines rewritten with it: §5.5 Enemy AI (the stand replaces the fixed 0.35 s pause); docs/SIM.md, docs/DATA.md (`attackAnim`), docs/ASSETS.md, docs/PLAYING.md. +- Tests: `test/sim/feedback2-enemy-attack-stand.test.js` (7 — `attackAnim` matches the manifest for every enemy; 隐形弩手 with an operator in range stands for its clip at each attack and walks between attacks; a 1 s attacker with a 2 s clip never walks while it has a target; an `attackMoves` enemy keeps walking while the same enemy without it stands; the fallback, and a stun ending the stand; on a 3 s WAIT checkpoint the wait ends at the same time with or without a target, and the enemy walks on once its current clip is over; DISAPPEAR / APPEAR legs proceed during the stands, and hiding ends the stand), `test/render/feedback2-enemy-attack-clip.test.js` (4, fake PIXI with the real manifest: wind-up, one clip per attack at its own speed, then Move; a quick attacker's clip at 2×, an attack seen late starts on its strike frame; an operator's loop unchanged; enemy views use a clip per attack, operator views do not); `test/sim/combat.test.js` keeps the 0.35 s fallback. + +### 22.15 An operator forced off the field lies down and comes back there (#60) — `sim/Battle.js isDown / retreat` (with `_remove`, `_layBody`, `restTile`, `downOn`, `_checkRedeploys`, `snapshot` unchanged), `content/kits/tier5.js` (余烬) + +- **Seen**: 「42突袭死后会消失」 (42 = 史尔特尔, a community nickname). **Real**: her talent 余烬 keeps her at 1 HP after a lethal blow and forces her out 8 s later (`battle.retreat`, reason `'retreat'`). `Battle.isDown` took only `'killed'` and `FORCED_EXIT`, so she left no body: no b.snap `down` entry, so the client played Die and faded her out, and no redeploy ring. `restTile` sent her home, so 70 s later she came back on her board tile, not where she fell. Measured on feedback2 6b634fe: on (10,3), out at 9 s, back at 79 s on (10,3), with nothing on the field in between; after a 突袭 jump to (12,8), out at 21 s, back at 90 s on (10,3), then she jumped again. The same path was used by 耀骑士临光 S2 (`tier6.js`), the 骑士戒律 + 竞技旗 combo (`items/battle.js`), 伊内丝 S3's first deployment (`tier4.js`) and a 商人 that cannot pay (`professions.js`, `tier3.js` 琳琅诗怀雅). +- **Official**: PRTS 卫戍协议/帮助 §作战阶段 单位部署: "干员退场后,将返回隐藏的待部署区,并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并自动部署至该位置。" This covers every 退场, not only a knock-out (the owner's decision, 2026-10-04: every operator that leaves the field leaves a 倒地干员 on that tile and is redeployed there). PRTS 史尔特尔 天赋 余烬: 「受到致命伤害时持续使生命值不低于1,8秒后强制退出战场」. Her page's 备注 "强制退出战场视为撤回干员" describes the regular game, where a withdrawal refunds part of the cost; 卫戍协议 has no withdrawal in battle. +- **Cause**: §21.20 / §21.24 [ASSUMED] "a withdrawn operator leaves no body": `isDown` listed two removal reasons. +- **Now**: `isDown` takes every removal of an operator that may come back, except the 突袭 retreat (`'raid'`, which redeploys at once on its landing tile): `'killed'`, `FORCED_EXIT` (联防), `'retreat'` (余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3) and `'merchant'`. The §21.24 rules then apply unchanged: + - `_remove` lays the body where the operator stood (its own home when that tile is another board piece's home and its home is free). + - b.snap `down` and the field meta list it, so the client draws the body and the ring. The client needed no change: it draws whatever `down` lists. + - `_checkRedeploys` / `redeploy()` bring it back on that tile, and `downOn` / `isReservedTile` keep allies off it. + - A forced exit is still no kill. Its death reason stays `'retreat'` / `'merchant'`: no `kill` hook, no 被击倒 effects, no 不屈 or 阿戈尔 redeploy, no knock-down count (`perPlayer.deaths`) and no knock-down sound. + - 联防 is unchanged: any operator not alive at the end of its own combat already entered 联防 down (`carryState.down`, `FORCED_EXIT`). + - After the fix, the same layouts: down on (10,3) — `down` [1, 79, 70, 0, 10, 3] — and back there at 79 s; after the jump, down on (12,8) and back on (12,8) at 90 s; 耀骑士临光 S2 lies on her tile with her ×1.25 redeploy ring. +- [ASSUMED]: `'merchant'` counts as a 退场 too (the PRTS sentence and the owner's "every operator that leaves the field"; the triage had left it out); the regular game's partial refund for a 强制退出 (视为撤回干员) is not modelled; the 突袭 retreat leaves no body, since it is redeployed on its landing tile in the same tick (should both tiles refuse it, it waits for its timer and comes back home). +- Normative lines rewritten with it: §5.4 DP / redeploy, §8.2 `down`, §21.20 ([ASSUMED] list), §21.24 (Now), §22.1 (the withdrawn-operator sentence); docs/SIM.md, docs/PLAYING.md. +- Tests: `test/sim/feedback2-forced-exit-down.test.js` (4 — 史尔特尔 after 余烬: `down` on her tile, the field meta, no kill / knock-down count, back on that tile after 70 s; after a 突袭 jump: down on the landing tile, the tile reserved, back there with her home unchanged; 耀骑士临光 S2: down on her tile with the ×1.25 ring and back there; a real knock-out unchanged); `test/sim/downelem.test.js` (a forced exit is `down`; the 突袭 retreat, permanent removals, summons, devices and enemies never are) and `test/sim/engine-requests.test.js` (a retreated operator lies where it stood and comes back there) follow the rule. + +### 22.16 干员调配 shows the stats, 攻击范围, 特性 and 天赋 the chosen skill / module fight with (#64) — `screens/loadout.js statsPreview / LoadoutStats`, `ui/detailPanel.js chessStatsBlock / chessTalents / traitText`, `css/screens/loadout.css .lo-sec--stats` + +- **Seen** (GitHub #64): the 干员调配 detail had only 技能 and 模组 — no 生命上限 / 攻击 / 防御 / 法术抗性 / 攻击间隔 / 阻挡数 / 部署费用 / 再部署, no 攻击范围, no 特性 or 天赋 of the operator (headless 1920 × 1080: block titles `["技能 SKILL", "模组 MODULE"]`, 0 `.dstats`, 0 `.rgrid`). A module card lists only its own overrides (`ModuleInfo`: 属性 +80 …), so a player could not tell what a module or a skill choice does to the operator without leaving for the board and opening its card. +- **Official**: no rule to look up — the screen adds none. The rule it serves is unchanged (§16, "开始游戏前无法调整干员的等级,但可调整其所携带的技能和模组"); the numbers shown are the ones the sim fights with, from the same composition (`shared/loadoutRecord.js`, §16): an elite's stats = `statsBase` + the module's `attr` (不装备: `statsBase`), its 特性 = the module's `traitOverride` (不装备: `traitBase`), its talents = the base ones + the module's `talentChanges`, its range = `attackRangeGrid` (a selected "被动效果:攻击范围扩大" skill, a module's own grid, the 特性's permanent 攻击距离 — §21.16). +- **Cause**: `Detail` rendered the two choice sections only; the card's stats block was inline in `ChessDetail` and its 特性 helper `traitText` private, so there was nothing to reuse. +- **Now**: a **局内数值** section between 技能 and 模组 (next to both choices; the first `.lo-seg` stays the skill-level toggle). `statsPreview(base, golden, entries, level, getChess)` picks the variant — the 精锐 record when asked for and the chess has one, else the normal one — and resolves it with `chessLoadout(variant, entries, getChess).record` (`gameLogic.js`, the call the shop card and the detail card make); no stat arithmetic of its own. `LoadoutStats` then draws `chessStatsBlock` (the card's eight-stat grid and 攻击范围 mini-map — the card now calls the same function, `blocks.stats`, so the two cannot drift), a 特性 / 天赋 card in the module-info card style (`traitText` and `chessTalents`, the card's own rules: the 特性 line exists when the chess has one and follows the chosen module; named, not hidden talents) and a caption. A 普通 / 精锐 toggle has its own state (`statLevel`, default **精锐**: the module exists only there, so a module click changes the numbers at once; it does not move the skill descriptions' toggle); 精锐 is disabled for a chess without an elite record. The numbers are the record's — no live entry, so none of the card's 实时 / 开战时 tag or green / red; the caption says what they leave out: "数值含所选模组;不含技能发动、装备、盟约等局内加成" (in 普通: "普通干员没有模组,所选模组在「精锐」中生效;…"). Measured over the data: 信仰搅拌机 SPT-Y 3 → 4 tiles, 莫斯提马 SPC-X 9 → 10, 引星棘刺 S3 13 → 19 (both variants); 隐现 elite 1,421 / 508 with MAR-X, 1,341 / 486 on 不装备, 1,123 / 399 as the normal chess; 异客's other module rewrites 机理分析 (80 % → 70 %). +- **Now (css)**: the card's `.dstats` / `.dstat` / `.drange` / `.rgrid` (game-panels.css, loaded on every page) at this screen's reading sizes — every text size keeps a px floor (labels 9, values 12, range cells 7 px at the phones' 1 rem = 40 px); the stats grid takes `flex: 1 1 max(3.2rem, 190px)` and the range box wraps under it when the column is narrower than about 260 px. Headless Chrome (`test/ui/loadout.e2e.test.js`, screenshots `test/e2e/out/loadout-stats-*.png`): 1920 × 1080 the grid with the range box beside it; 844 × 390 (a 240 px detail column) the range box under the numbers, no sideways overflow, no label cut off; 667 × 375 (the detail slid over the roster) beside again. Portrait phones get the app's "rotate device" screen, so they are no target. +- [ASSUMED]: the section's place (the issue offers before 技能 or after 模组 — between them keeps both choices within a screen of the numbers on a 1080p display), 精锐 as the default variant, the caption wording, no difference colouring against 不装备, and that the 特质 (garrison) block is not repeated (the issue names 特性 and 天赋 only). +- Tests: `test/ui/loadout-stats.test.js` (9: the variant and the loadout resolution, the sim's range changes, the section against `ChessDetail` for 11 chess / loadout / level cases, the eight values, 特性 / 天赋 following the module, the toggle, the section's place, the css floors), `test/ui/feedback1e-range.test.js` (the source contract now points at `chessStatsBlock`), `test/ui/loadout.e2e.test.js` (+2 opt-in browser tests: desktop and 844 × 390 touch). +- **Review (Grok, 2026-10-04)**: the 特性 line fell back to the class trait on a record cloned for a non-default skill or module (e.g. 隐现 on S1 showed 优先攻击空中单位 instead of MAR-X's 攻击空中单位时攻击力提升至110%; 信仰搅拌机 SPT-Y showed the class trait instead of 攻击距离+1) — the in-match card shared the bug since 0.1.1. `traitText` now returns the record's own module line whenever there is one (a 不装备 record carries none: checked on all 133 elite records); test in `test/ui/loadout-stats.test.js`.