diff --git a/docs/SIM.md b/docs/SIM.md index b261be9c..804bf16d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -571,7 +571,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged | `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place | | `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 | | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`) unless `dir` (directional: 推击手, 朝部署方向 — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse). A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) | -| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | +| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | | `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) | | `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random | | `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked | diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 9bd2d47c..50ddc09d 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -842,25 +842,32 @@ function husk({ hits, delay, stealthy = true, onHusk = null, key = 'ab:ember' }) }; } -/** 守墓石像: first knock-out → statue (DEF/RES up, immobile) → reborn as a flyer. */ +/** + * 守墓石像 / 愤怒的守墓石像 (PRTS 天赋): first knock-out → 转换模式 for `stone.duration` s, "无法被阻挡,自缚,失衡免疫,免疫浮空。 + * 防御力增加800,法术抗性增加30" → 飞行模式, "变为飞行单位,失衡免疫": a flyer no push or pull moves. Its pages list no 静态刚体 + * (data `staticBody` stays off), so the 失衡免疫 of the flight is a persistent `noDisplace` buff. 浮空 is refused from the + * statue on (`ab.immune`; data flyers refuse it anyway). [ASSUMED] a 浮空 it already carries at the knock-out runs out. + */ function statue(ab) { const dur = T(ab, 'stone.duration') ?? 0; return { - killed(c, b, e, a) { + killed(c, b, e, a, ab2) { if (a.done || !(dur > 0)) return false; a.done = true; a.noAtk = e.profile.noAttack; e.profile.noAttack = true; e.hp = e.s.maxHp; + ab2.immune = new Set([...(ab2.immune || []), 'levitate']); b.fx('stone', { x: e.x, y: e.y, id: e.id, dur }); b.addBuff(e, { - key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true }, + key: 'ab:stone', duration: dur, visible: true, flags: { noMove: true, unblockable: true, noDisplace: true }, mods: { defFlat: T(ab, 'stone.def') ?? 0, resFlat: T(ab, 'stone.magic_resistance') ?? 0 }, onExpire: ({ battle }) => { if (!e.alive) return; e.motion = 'FLY'; e.profile.noAttack = a.noAtk; e.hp = e.s.maxHp; + battle.addBuff(e, { key: 'ab:statueFly', persist: true, flags: { noDisplace: true } }); battle.addBuff(e, { key: 'ab:liftoff', duration: 0.2, flags: { unblockable: true } }); // releases its blocker if (e.route) e.route.pts = null; battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly' }); @@ -1724,10 +1731,16 @@ function kitLeaderMisc(key, ab, e) { case 'enemy_2004_balloon': { // 喷气人 · 升空 when blocked → 飞行模式 for `duration` s: PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行 // 攻击" — an air unit meanwhile (flag `float`: melee cannot hit it; it keeps the ground path, as setFloat), never - // displaced. [ASSUMED] not modelled: the −90 % move speed for 1.5 s after take-off and 1.333 s after landing. + // displaced; "切换为飞行模式后,1.5秒内移动速度最终降低90% … 结束后,1.333秒内不可阻挡,移动速度最终降低90%" (PRTS numbers: + // the blackboard has only the duration and the +50 %). const s = ab.sk.TakeOff; + const BRAKE = { moveMul: 0.1 }, BRAKE_UP = 1.5, LANDING = 1.333; return [skill(s, (b, e2) => { - b.addBuff(e2, { key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true, float: true, noDisplace: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true }); + b.addBuff(e2, { + key: 'ab:takeoff', duration: s.bb.duration ?? 0, flags: { unblockable: true, float: true, noDisplace: true }, mods: { moveMul: 1 + (s.bb['balloon_s[fly].move_speed'] ?? 0) }, visible: true, + onExpire: ({ battle }) => { if (e2.alive) battle.addBuff(e2, { key: 'ab:landing', duration: LANDING, flags: { unblockable: true }, mods: BRAKE }); }, + }); + b.addBuff(e2, { key: 'ab:takeoffBrake', duration: BRAKE_UP, mods: BRAKE }); b.fx('telegraph', { x: e2.x, y: e2.y, r: 0.5, kind: 'takeoff', id: e2.id }); }, { cond: (b, e2) => !!e2.blockedBy })]; } @@ -2457,7 +2470,7 @@ export const KITS = Object.freeze({ enemy_1513_dekght_2: (ab, e) => kitLeaderMisc('enemy_1513_dekght_2', ab, e), // 凋零骑士 · 2 targets; rage when 腐败骑士 dies enemy_1539_reid: (ab, e) => kitLeaderMisc('enemy_1539_reid', ab, e), // “复仇者” · ATK up below half; revives once at 50 % enemy_2003_rockman: (ab, e) => kitLeaderMisc('enemy_2003_rockman', ab, e), // 迷路的巨像 · long-stun boulder on a non-stunned unit - enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off (unblockable) when blocked + enemy_2004_balloon: (ab, e) => kitLeaderMisc('enemy_2004_balloon', ab, e), // 喷气人 · takes off when blocked: 7 s hovering, unblockable, 失衡免疫 enemy_2005_axetro: (ab, e) => kitLeaderMisc('enemy_2005_axetro', ab, e), // “遗弃者” · attack stacks, reset after 4 s idle enemy_2008_flking: (ab, e) => kitLeaderMisc('enemy_2008_flking', ab, e), // “墓碑” · operators' ATK/DEF halved; periodic barrier enemy_2048_smgrd: (ab, e) => kitLeaderMisc('enemy_2048_smgrd', ab, e), // “邪魔的利刃” · 国度 ASPD-down around it, ×2.5 inside diff --git a/test/sim/feedback1b-displacement.test.js b/test/sim/feedback1b-displacement.test.js index 29c9f0e2..c259f1a6 100644 --- a/test/sim/feedback1b-displacement.test.js +++ b/test/sim/feedback1b-displacement.test.js @@ -10,6 +10,8 @@ // - the skills keep their reach: 薄绿 (阵法术师 "攻击时可对空"), 锏 S3 ("※可对空"), the 钩索师 … still hit the drones. // - 刺胄之弹 / “斩胄之剑” / “破胄之锤” are also 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},…失衡免疫…"). // - 喷气人's 飞行模式 (PRTS 喷气人 "近地悬浮,不可阻挡,失衡免疫,移动速度+50%,不进行攻击") hovers like the two 近地悬浮 enemies. +// - 守墓石像 turns into a flyer at run time (data WALK, no 静态刚体): its statue ("无法被阻挡,自缚,失衡免疫,免疫浮空") and its +// flight ("变为飞行单位,失衡免疫") are 失衡免疫 (PRTS 守墓石像 / 愤怒的守墓石像 天赋). // Unchanged: ground enemies keep the 力度 − 重量 tables (DESIGN §20.3); the hovering 吉兆飞鳞 / 掠海漂移体 are 失衡免疫 while they // hover and displaceable once grounded (no 静态刚体 on their pages). [ASSUMED] the 0.1 s 失衡硬直 of a 静态刚体 is not modelled. @@ -354,7 +356,7 @@ test('B5: 刺胄之弹 / 斩胄之剑 / 破胄之锤 are 静态刚体 + 失衡 } }); -test('喷气人 飞行模式 = 近地悬浮 + 不可阻挡 + 失衡免疫 (PRTS 喷气人): an air unit nothing moves; on the ground again afterwards', REAL, () => { +test('喷气人 飞行模式 = 近地悬浮 + 不可阻挡 + 失衡免疫 (PRTS 喷气人): an air unit nothing moves; then 1.333 s unblockable on the ground', REAL, () => { const ds = getDefaultSource(); const s = ds.rawEnemy(BALLOON).skills.find((k) => k.prefabKey === 'TakeOff'); const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }); @@ -363,15 +365,65 @@ test('喷气人 飞行模式 = 近地悬浮 + 不可阻挡 + 失衡免疫 (PRTS units: [{ chessId: 't_wall', row: 9, col: 5 }], autoFinish: false, timeLimit: 60, }); h.step(); - const e = h.spawn(BALLOON, { pos: [9, 5], routeIndex: 0, mods: { speedMul: 0, atkMul: 0 } }); + // nearly still (speed × 1e-3), so it stays on the wall's tile while the speed ratios stay measurable + const e = h.spawn(BALLOON, { pos: [9, 5], routeIndex: 0, mods: { speedMul: 1e-3, atkMul: 0 } }); + const speed = () => e.s.moveSpeed / e.base.moveSpeed; + const fly = 1 + s.bb['balloon_s[fly].move_speed']; assert.ok(h.runUntil(() => !!e.findBuff('ab:takeoff'), s.initCooldown + 1), 'takes off when blocked'); + const t0 = h.b.time; h.step(); assert.ok(e.isFlying && e.s.flags.unblockable && e.s.flags.noDisplace && !e.blockedBy, '近地悬浮, 不可阻挡, 失衡免疫'); assert.equal(e.motion, 'WALK', 'keeps the ground path'); + // "切换为飞行模式后,1.5秒内移动速度最终降低90%" + approx(speed(), fly * 0.1, 1e-9, 'braking after the take-off'); // 较大力 (2) − weight 3 = −1 would move a ground 喷气人 35 % of the way assert.equal(h.b.pull(e, 2, { to: { x: 9, y: 9 }, stop: 0 }), 0, 'not pulled'); assert.equal(h.b.push(e, 3, { from: { x: 3, y: 9 } }), 0, 'not pushed'); - h.run(s.bb.duration + 0.2); - assert.ok(!e.findBuff('ab:takeoff') && !e.isFlying && !e.s.flags.noDisplace, 'landed: a ground unit'); + h.run(1.6); + approx(speed(), fly, 1e-9, '移动速度+50%'); + // "飞行模式持续7秒;结束后,1.333秒内不可阻挡,移动速度最终降低90%" + h.runUntil(() => !e.findBuff('ab:takeoff'), s.bb.duration); + approx(h.b.time - t0, s.bb.duration, 0.05, '7 s of flight'); + h.step(); + assert.ok(!e.isFlying && !e.s.flags.noDisplace, 'landed: a ground unit'); + assert.ok(e.s.flags.unblockable && !e.blockedBy, 'unblockable for a while'); + approx(speed(), 0.1, 1e-9, 'braking after the landing'); approx(h.b.push(e, 3, { from: { x: 3, y: 9 } }), PUSH_TILES[0], 1e-9, '大力 − 3 = 0 → 1.7 tiles'); + h.run(1.4); + assert.ok(!e.s.flags.unblockable, 'blockable again after 1.333 s'); + approx(speed(), 1, 1e-9, 'its own speed'); +}); + +test('守墓石像 (PRTS 天赋): the statue (无法被阻挡,自缚,失衡免疫,免疫浮空) and the flight (飞行单位,失衡免疫) — nothing moves it, even 失重', REAL, () => { + const wall = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }); + for (const key of ['enemy_1172_dugago', 'enemy_1172_dugago_2']) { + const h = makeBattle({ + defs: { chess: { t_wall: wall } }, kits: { t_wall: () => ({ trait: { noAttack: true } }) }, + units: [{ chessId: 't_wall', row: 10, col: 6 }], autoFinish: false, timeLimit: 60, + }); + h.step(); + const e = h.spawn(key, { pos: [10, 6], routeIndex: 0, mods: { speedMul: 0, atkMul: 0 } }); + h.step(); + assert.ok(e.blockedBy && !e.def.staticBody && e.s.massLevel === 4, `${key}: blocked, a dynamic body of weight 4`); + const dur = e.def.talent['stone.duration']; + h.b.kill(e, null); + h.step(); + assert.ok(e.alive && e.findBuff('ab:stone'), `${key}: the statue`); + assert.ok(e.s.flags.unblockable && !e.blockedBy && e.s.flags.noMove && e.s.flags.noDisplace, `${key}: 无法被阻挡, 自缚, 失衡免疫`); + assert.equal(h.b.applyStatus(e, 'levitate', { duration: 2 }), false, `${key}: 免疫浮空`); + // 失重 (massFlat −1 → weight 3): 较大力 (2) − 3 = −1 would pull it 35 % of the way, 大力 (3) − 3 = 0 push it 1.7 tiles + h.b.addBuff(e, { key: 'test:weightless', duration: 60, mods: { massFlat: -1 } }); + assert.equal(e.s.massLevel, 3); + const at = { x: e.x, y: e.y }; + assert.equal(h.b.pull(e, 2, { to: { x: 2, y: 10 }, stop: 0 }), 0, `${key}: statue not pulled`); + assert.equal(h.b.push(e, 3, { from: { x: 4, y: 10 } }), 0, `${key}: statue not pushed`); + h.run(dur + 0.5); + assert.ok(e.alive && e.motion === 'FLY' && e.isFlying && !e.findBuff('ab:stone'), `${key}: reborn as a flyer`); + assert.ok(e.s.flags.noDisplace && !e.def.staticBody, `${key}: 失衡免疫 (not a 静态刚体)`); + assert.equal(h.b.pull(e, 2, { to: { x: 2, y: 10 }, stop: 0 }), 0, `${key}: flyer not pulled`); + assert.equal(h.b.push(e, 3, { from: { x: 4, y: 10 } }), 0, `${key}: flyer not pushed`); + assert.equal(h.b.pullToFront(e, h.unit('t_wall'), 5), 0, `${key}: flyer not hooked`); + approx(Math.hypot(e.x - at.x, e.y - at.y), 0, 1e-9, `${key}: in place`); + checkInvariants(h.b); + } });