feedback2 WI: 归溟幽灵鲨's 替身 makes no normal attack and casts no skill, her S2 end switches her to it directly; dollkeepers switch with a 1 s animation, hold 阻回 and keep their own max HP (issue #44)
This commit is contained in:
1 parent
47df17171a
commit
64f715fb20
9 files changed
+311
-49
No files matched your search
+8
-4
@@ -311,7 +311,10 @@ instance or a block starts a 2 s change). Each form change goes through `setForm
|
||||
unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched
|
||||
teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the
|
||||
fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive'
|
||||
('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge
|
||||
('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. An operator has one
|
||||
form too: a 傀儡师's <替身> (`professions.js installDollkeeper`: `u.form` 'doll' from the switch to it — fx 'substitute'
|
||||
`{ form: 'doll', dur }` — until the switch back, fx 'swap' `{ form: null }`, or its knock-out, fx 'dollEnd' `{ form: null }`
|
||||
after the 'die' event). Barrier / charge
|
||||
'phase' kinds carry no `form` and are no forms. A form is state, not decoration (b.snap tuples carry none): the client
|
||||
keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames
|
||||
and its hidden-tab backlog (`battle/runner.js keepsState`; a backlog past `HELD_MAX` drops only superseded status /
|
||||
@@ -669,6 +672,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
|
||||
| `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set |
|
||||
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
|
||||
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) |
|
||||
| `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` |
|
||||
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
|
||||
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) |
|
||||
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart |
|
||||
@@ -1088,7 +1092,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
|
||||
| agent / hookmaster | can hit FLY (ranged reach); hook displacement comes from skills (generic: `force`) |
|
||||
| bearer | block 0 while the skill is active |
|
||||
| alchemist | ranged lob, can hit FLY |
|
||||
| dollkeeper | fatal damage ⇒ substitute for 20 s (bb duration): block 0, doll HP (its own substitute token's stats, else 50 % max HP: 归溟幽灵鲨); swaps back at full HP; dies if the doll dies |
|
||||
| dollkeeper | fatal damage (no 不死) ⇒ a 1 s switch animation [ASSUMED length] (无敌, 不死, 阻回, 禁疗, 孤立, 缴械, 眩晕 / 冻结 / 睡眠 immune; ends the running skill and removes the statuses), then the <替身> for 20 s (bb duration): block 0 from the switch on, 阻回, HP = its own 替身 token's (风丸 纸偶), else its own max HP (归溟幽灵鲨 [ASSUMED]); then the switch back (the same animation) at full HP; dies if the 替身 dies. Kit flag `dollNoAttack` (归溟幽灵鲨): the 替身 makes no normal attack and casts no skill. Hook `dollSwitch` switches it at once (归溟幽灵鲨 S2's end). Model form `'doll'` (PRTS 分支特性信息 傀儡师, DESIGN §22.11) |
|
||||
| geek | loses 1–3 % max HP per second (bb hp_ratio), never lethal on its own |
|
||||
| merchant | −3 DP every 3 s (bb cost/interval); retreats when DP runs out |
|
||||
| skywalker | can block FLY enemies (蒂比's kit: only while airborne — 起飞, flag `liftoff`, which also releases the ground enemies she blocked) |
|
||||
@@ -1116,9 +1120,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['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]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
|
||||
`extra.form` = the enemy's model form from then on, `content/enemies.js setForm` / `shared/protocol.js fxForm`),
|
||||
`extra.form` = the unit's model form from then on — an enemy's `content/enemies.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
|
||||
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
|
||||
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms … — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
|
||||
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
|
||||
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
|
||||
(`spine`/`avatar` are asset ids from data).
|
||||
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
// every MANUAL skill; AUTO skills never take a class row).
|
||||
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
|
||||
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
|
||||
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
|
||||
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'crit',
|
||||
// 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'.
|
||||
|
||||
import { COLS } from '../../constants.js';
|
||||
@@ -1633,10 +1633,13 @@ const KITS = {
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out
|
||||
// (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK
|
||||
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team
|
||||
// max HP +20 %. Module (elite): substitute ATK +15 %.
|
||||
// 归溟幽灵鲨 — dollkeeper (professions.js installDollkeeper: the 替身 form, its switch animations, 阻回). Her <替身> makes
|
||||
// no normal attack (PRTS 特性备注 "<替身>不进行普通攻击") and so casts no skill (kit trait `dollNoAttack`).
|
||||
// S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1 (PRTS 备注: 不死 — a lethal hit does not switch her meanwhile);
|
||||
// when it ends she switches to the 替身 at once (PRTS 修正 "技能结束后立刻切换为<替身>", the text's 视为被击倒: no lethal
|
||||
// HP loss, so no 不死 / 复活 effect takes it). T1 拥抱自我: the 替身 slows nearby enemies −40 % and deals 40 % ATK
|
||||
// arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only) — once it fights (not during its switch
|
||||
// animation). T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %.
|
||||
// S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio,
|
||||
// ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio
|
||||
// is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP.
|
||||
@@ -1676,21 +1679,23 @@ const KITS = {
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }),
|
||||
// "技能结束后立刻切换为<替身>": nothing when the skill ended with her (death) or by her switch to the 替身, or she
|
||||
// already is one
|
||||
onEnd({ battle, unit, reason }) {
|
||||
if (reason === 'death' || !unit.alive || !unit.deployed) return;
|
||||
battle.fx('knockout', { x: unit.x, y: unit.y, id: unit.id });
|
||||
battle.loseHp(unit, unit.hp + 1, { source: unit });
|
||||
if (reason === 'death' || reason === 'substitute' || !unit.alive || !unit.deployed || unit.trait.doll) return;
|
||||
battle.emit('dollSwitch', { unit, reason: 'skill', done: false });
|
||||
},
|
||||
},
|
||||
trait: { dollNoAttack: true },
|
||||
talents: [
|
||||
{ install(battle, unit) { // 拥抱自我
|
||||
{ install(battle, unit) { // 拥抱自我 (the 替身 fighting: not during a switch animation)
|
||||
const slow = num(t0.move_speed), scale = num(t0.atk_scale);
|
||||
whileOn(battle, unit, AURA_IV, () => {
|
||||
if (!unit.trait.doll || !slow) return;
|
||||
if (!unit.trait.doll || unit.trait.dollSwitch || !slow) return;
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } });
|
||||
});
|
||||
whileOn(battle, unit, 1, () => {
|
||||
if (!unit.trait.doll || !(scale > 0)) return;
|
||||
if (!unit.trait.doll || unit.trait.dollSwitch || !(scale > 0)) return;
|
||||
for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] });
|
||||
});
|
||||
} },
|
||||
|
||||
+82
-18
@@ -22,6 +22,7 @@
|
||||
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
|
||||
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
|
||||
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
|
||||
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
|
||||
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
|
||||
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
|
||||
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
|
||||
@@ -90,33 +91,96 @@ const installCharger = (battle, unit) => {
|
||||
}, { owner: unit });
|
||||
};
|
||||
|
||||
/**
|
||||
* 傀儡师 (dollkeeper) trait "受到致命伤时不撤退,切换成<替身>作战(替身阻挡数为0),持续20秒后自身再次替换<替身>" with the
|
||||
* branch rules of PRTS 分支特性信息 傀儡师: a lethal hit on the <本体> (one it survives with 不死 does not count) starts a
|
||||
* switch animation, after which the operator fights as its <替身> for the trait's 20 s (bb duration) holding 阻回; then
|
||||
* it switches back the same way. A switch animation clears the operator's Buffs and resets its HP to the max of the form
|
||||
* it switches to; during one the operator holds 不死, 无敌, 阻回, 禁疗, 孤立, 强制缴械 and is immune to 眩晕 / 冻结 / 睡眠.
|
||||
* The 替身 blocks nothing from the start of the switch to it until the switch back starts; a lethal hit on it knocks the
|
||||
* operator out. Content may switch the operator at once (hook `dollSwitch` { unit, reason, done }: 归溟幽灵鲨 S2 "技能结束
|
||||
* 后立刻切换为<替身>"). A kit's `dollNoAttack` profile flag (归溟幽灵鲨, PRTS 特性备注 "<替身>不进行普通攻击") disarms the 替身
|
||||
* and keeps it from casting (the basic 技能策略 casts on an attack the 替身 never makes; [ASSUMED] every other rule — S1's
|
||||
* 技能范围 — likewise); 风丸's 替身 attacks (PRTS: "<替身>状态下可对空").
|
||||
* The form is the unit's model state (`unit.form` 'doll', snapshot.js unitInfo; fx 'substitute' / 'swap' / 'dollEnd' with
|
||||
* `form`, shared/protocol.js fxForm): the client draws the 替身 on the skeleton's *_B clips (render/units.js FORMS).
|
||||
* [ASSUMED]: a switch animation lasts DOLL_SWITCH (the skeletons' 1 s Start_B — the 替身 appearing — and Start_2 — the
|
||||
* 本体 back); "清除自身一切Buff" ends the running skill and removes the statuses (buffs with a catalogue `status`) — the
|
||||
* remake's bond / item / talent effects are buffs too and stay; a dollkeeper without a 替身 token of its own (归溟幽灵鲨) keeps
|
||||
* its own max HP (PRTS: HP "重设…至最大值"; character_table has no 替身 of hers, and 风丸's 纸偶 token has her HP).
|
||||
*/
|
||||
export const DOLL_SWITCH = 1;
|
||||
const DOLL_SWITCH_IMMUNE = new Set(['stun', 'freeze', 'sleep']);
|
||||
|
||||
const installDollkeeper = (battle, unit) => {
|
||||
// the substitute's HP comes from the unit's own substitute token (风丸 纸偶) for its selected skill / module
|
||||
// (DESIGN §16); a dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats → 50 % of its
|
||||
// own max HP
|
||||
const dollHp = () => {
|
||||
// the 替身's max HP comes from the unit's own 替身 token (风丸 纸偶) for its selected skill / module (DESIGN §16); a
|
||||
// dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats: its own max HP (header)
|
||||
const dollHpMul = () => {
|
||||
const tokId = (unit.def.tokens || []).map((t) => (typeof t === 'string' ? t : t?.tokenId)).find((t) => t && /shadow|doll/.test(t));
|
||||
const tok = tokId ? battle.data.getToken?.(tokId, unit.defId, unit.def?.loadout ?? null) : null;
|
||||
return tok && tok.stats.maxHp > 0 ? tok.stats.maxHp : unit.base.maxHp * 0.5;
|
||||
return tok && tok.stats.maxHp > 0 ? Math.max(0.05, tok.stats.maxHp / Math.max(1, unit.base.maxHp)) : 1;
|
||||
};
|
||||
battle.on('fatal', (ctx) => {
|
||||
if (ctx.unit !== unit || ctx.prevented || unit.trait.doll) return;
|
||||
ctx.prevented = true;
|
||||
unit.trait.doll = true;
|
||||
const at = (extra) => ({ x: unit.x, y: unit.y, id: unit.id, ...extra });
|
||||
// a switch animation: "切换动画开始时会清除自身一切Buff" (header: the running skill and the statuses), then 1 s of 不死
|
||||
// (the fatal hook below) 无敌 阻回 禁疗 孤立 强制缴械 and 眩晕 / 冻结 / 睡眠 immunity (beforeStatus below)
|
||||
const startSwitch = () => {
|
||||
const sk = unit.skill;
|
||||
if (sk && sk.active && sk.kind !== 'passive') sk.end('substitute');
|
||||
for (const b of unit.buffs.slice()) if (b.status) battle.removeBuff(unit, b);
|
||||
unit.trait.dollSwitch = true;
|
||||
const done = () => { unit.trait.dollSwitch = false; };
|
||||
battle.addBuff(unit, {
|
||||
key: 'trait:substitute', duration: unit.profile.dollDuration ?? 20, visible: true,
|
||||
mods: { blockCnt: -99, hpMul: Math.max(0.05, dollHp() / Math.max(1, unit.base.maxHp)) },
|
||||
onExpire: () => {
|
||||
unit.trait.doll = false;
|
||||
if (unit.alive) { unit.markDirty(); unit.hp = unit.s.maxHp; battle.fx('swap', { x: unit.x, y: unit.y, id: unit.id }); }
|
||||
},
|
||||
key: 'trait:dollSwitch', duration: DOLL_SWITCH, onExpire: done, onRemove: done,
|
||||
flags: { invulnerable: true, noSp: true, noHeal: true, isolated: true, disarm: true },
|
||||
});
|
||||
battle.releaseBlocked(unit);
|
||||
};
|
||||
const leave = () => {
|
||||
if (!unit.trait.doll) return;
|
||||
unit.trait.doll = false;
|
||||
unit.form = null;
|
||||
if (!unit.alive || !unit.deployed) return;
|
||||
startSwitch();
|
||||
unit.markDirty();
|
||||
unit.hp = unit.s.maxHp;
|
||||
battle.fx('substitute', { x: unit.x, y: unit.y, id: unit.id });
|
||||
battle.fx('swap', at({ form: null }));
|
||||
};
|
||||
const enter = () => {
|
||||
if (unit.trait.doll || !unit.alive || !unit.deployed) return false;
|
||||
unit.trait.doll = true;
|
||||
startSwitch();
|
||||
const dur = DOLL_SWITCH + (unit.profile.dollDuration ?? 20);
|
||||
const mul = dollHpMul();
|
||||
battle.addBuff(unit, {
|
||||
key: 'trait:substitute', duration: dur, visible: true, onExpire: leave, onRemove: leave,
|
||||
mods: { blockCnt: -99, ...(mul !== 1 ? { hpMul: mul } : null) },
|
||||
// 阻回 for the whole form; a 替身 that makes no normal attack casts no skill either (header)
|
||||
flags: { noSp: true, ...(unit.profile.dollNoAttack ? { disarm: true, silence: true } : null) },
|
||||
});
|
||||
battle.releaseBlocked(unit);
|
||||
unit.form = 'doll';
|
||||
unit.markDirty();
|
||||
unit.hp = unit.s.maxHp;
|
||||
// `dur`: until the switch back (the client times the 替身's closing clip with it)
|
||||
battle.fx('substitute', at({ form: 'doll', dur }));
|
||||
return true;
|
||||
};
|
||||
battle.on('fatal', (ctx) => {
|
||||
if (ctx.unit !== unit || ctx.prevented) return;
|
||||
if (unit.trait.dollSwitch) { ctx.prevented = true; return; } // 不死 while switching (a 流失 ignores 无敌)
|
||||
if (unit.trait.doll) return; // the 替身 is knocked out
|
||||
ctx.prevented = enter();
|
||||
}, { owner: unit, priority: -100 });
|
||||
battle.on('death', (ctx) => { if (ctx.unit === unit) unit.trait.doll = false; }, { owner: unit });
|
||||
battle.on('dollSwitch', (ctx) => { if (ctx.unit === unit && !ctx.done) ctx.done = enter(); }, { owner: unit });
|
||||
battle.on('beforeStatus', (ctx) => {
|
||||
if (ctx.target === unit && unit.trait.dollSwitch && DOLL_SWITCH_IMMUNE.has(ctx.status)) ctx.cancel = true;
|
||||
}, { owner: unit });
|
||||
battle.on('death', (ctx) => {
|
||||
if (ctx.unit !== unit) return;
|
||||
unit.trait.doll = false;
|
||||
unit.trait.dollSwitch = false;
|
||||
// knocked out as the 替身: its death clip has played (the 'die' event came first); the redeploy is the 本体 again
|
||||
if (unit.form) { unit.form = null; battle.fx('dollEnd', at({ form: null })); }
|
||||
}, { owner: unit });
|
||||
};
|
||||
|
||||
const installLibrator = (battle, unit) => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//
|
||||
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
|
||||
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
|
||||
// form?, skillIndex?, moduleId?, items? } (form = an enemy's current model form, content/enemies.js setForm — 掠海漂移体
|
||||
// 'crawl', 暴鸰 'bombed', 转译基底·α's forms …: a view built after the change, a field opened mid-battle, draws it —
|
||||
// render/units.js FORMS)
|
||||
// form?, skillIndex?, moduleId?, items? } (form = the unit's current model form — an enemy's, content/enemies.js setForm:
|
||||
// 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built
|
||||
// after the change, a field opened mid-battle, draws it: render/units.js FORMS)
|
||||
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
|
||||
// items = an ally operator's equipped item ids (absent without any).
|
||||
// flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it
|
||||
@@ -37,8 +37,8 @@ export function unitInfo(u) {
|
||||
maxHp: Math.max(1, Math.round(u.s.maxHp)),
|
||||
motion: u.motion === 'FLY' ? 'FLY' : undefined,
|
||||
boss: u.isBoss ? true : undefined,
|
||||
// an enemy's current model form (content/enemies.js setForm, render/units.js FORMS): a view built mid-battle
|
||||
// (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set
|
||||
// the unit's current model form (an enemy's content/enemies.js setForm, a 傀儡师's 替身 — render/units.js FORMS): a
|
||||
// view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set
|
||||
form: typeof u.form === 'string' ? u.form : undefined,
|
||||
uid: u.uid ?? undefined,
|
||||
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound)
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ export class Unit {
|
||||
this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 };
|
||||
this.hidden = false; // enemies inside a DISAPPEAR segment
|
||||
this.moving = false; // enemies: walked this tick (drawn on the move clip; ai.js updateEnemy)
|
||||
this.form = null; // enemies: the model's current form (content/enemies.js setForm → snapshot.js unitInfo)
|
||||
this.form = null; // the model's current form (content/enemies.js setForm, a 傀儡师's 替身 'doll' → snapshot.js unitInfo)
|
||||
this.anim = 0;
|
||||
this.persist = { redeployMul: 1, freeRedeploys: 0 };
|
||||
this.isBoss = false;
|
||||
|
||||
+2
-2
@@ -337,8 +337,8 @@ export const EV = Object.freeze({
|
||||
|
||||
/**
|
||||
* The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies.js
|
||||
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a string is
|
||||
* that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
|
||||
* setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a 傀儡师's 替身,
|
||||
* sim professions.js; a string is that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses
|
||||
* the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab
|
||||
* backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// test/sim/feedback2-doll.test.js — GitHub issue #44 「归鲨死了没替身」 (DESIGN §22.11), sim side. 归溟幽灵鲨
|
||||
// (chess_char_5_13_a / _b) is a 傀儡师 (server/sim/professions.js installDollkeeper). Official: PRTS 分支特性信息 傀儡师 —
|
||||
// a lethal hit on the <本体> without 不死 starts a switch animation, then she fights as her <替身> for 20 s holding 阻回
|
||||
// (block 0), then switches back; PRTS 归溟幽灵鲨 特性备注 "<替身>不进行普通攻击"; S2 生存的渴望 holds 不死 while it runs and
|
||||
// "技能结束后立刻切换为<替身>" (PRTS's correction of 视为被击倒). 0.1.1: the 替身 attacked, could cast S2 (whose end then
|
||||
// killed her) and the client never drew it (no model form). Cases A–D of the report's repro + 风丸 (her 替身 attacks).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { DOLL_SWITCH } from '../../server/sim/professions.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { fxForm } from '../../shared/protocol.js';
|
||||
|
||||
const skip = !hasGeneratedData();
|
||||
const G = 'chess_char_5_13_a';
|
||||
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
|
||||
const dummy = () => enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 0 });
|
||||
|
||||
/** 归溟幽灵鲨 on (9,5) facing right; a harmless parked enemy on (9,6) (in her range) unless `enemy` is false. */
|
||||
function setup({ id = G, skillIndex = 1, sp = 0, enemy = true, capture = false } = {}) {
|
||||
const h = makeBattle({
|
||||
seed: 4, timeLimit: 300, autoFinish: false, captureNoisy: capture,
|
||||
defs: { enemies: { enemy_dummy: dummy() } },
|
||||
units: [{ chessId: id, row: 9, col: 5, dir: 'RIGHT', skillIndex, carryState: { sp } }],
|
||||
enemies: enemy ? [{ key: 'enemy_dummy', pos: [9, 6] }] : [],
|
||||
});
|
||||
h.step();
|
||||
const g = h.unit(id);
|
||||
const log = { attacksAsDoll: 0, attacks: 0, castsAsDoll: [], casts: [] };
|
||||
h.b.on('attack', (c) => { if (c.attacker === g) { log.attacks++; if (g.trait.doll) log.attacksAsDoll++; } }, { priority: -2000 });
|
||||
h.b.on('skillStart', (c) => { if (c.unit === g) { log.casts.push(h.b.time); if (g.trait.doll) log.castsAsDoll.push(c.skill.id); } }, { priority: -2000 });
|
||||
const lethal = () => h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
|
||||
const fx = (kind) => h.eventsOf('fx').filter((e) => e[1] === kind && e[4]?.id === g.id);
|
||||
return { h, g, log, lethal, fx };
|
||||
}
|
||||
|
||||
test('A: a lethal hit with no skill running ⇒ the 替身 (form, full HP, block 0, 阻回), no normal attack, 拥抱自我 works; back after the 1 s switch + 20 s', { skip }, () => {
|
||||
for (const [id, skillIndex] of [[G, 1], [G, 0], [G, 2], ['chess_char_5_13_b', 1]]) {
|
||||
const { h, g, log, lethal, fx } = setup({ id, skillIndex, capture: true });
|
||||
h.run(1.5);
|
||||
assert.ok(log.attacks > 0, `${id}/${skillIndex}: the 本体 attacks`);
|
||||
const atk0 = log.attacks;
|
||||
const full = g.s.maxHp;
|
||||
const sp = g.skill.sp;
|
||||
lethal();
|
||||
assert.ok(g.alive && g.trait.doll && g.form === 'doll', `${id}/${skillIndex}: the 替身`);
|
||||
approx(g.hp, g.s.maxHp, 'full HP');
|
||||
approx(g.s.maxHp, full * (1 + (g.def.traitBb.max_hp || 0)), 'her own max HP (no 替身 token) [ASSUMED]');
|
||||
assert.equal(g.s.blockCnt, 0);
|
||||
const [sub] = fx('substitute');
|
||||
assert.ok(sub && fxForm(sub) === 'doll', 'the fx puts the model in its 替身 form');
|
||||
approx(sub[4].dur, DOLL_SWITCH + 20, 'until the switch back');
|
||||
h.run(DOLL_SWITCH + 19);
|
||||
assert.equal(log.attacks, atk0, 'no normal attack as the 替身 (PRTS 特性备注)');
|
||||
approx(g.skill.sp, sp, '阻回: the SP waits');
|
||||
const e = h.enemy('enemy_dummy');
|
||||
assert.ok(e.findBuff('ghost2:embrace'), '拥抱自我 slows');
|
||||
assert.ok(h.hooksOf('damaged').some((c) => c.source === g && c.target === e && (c.dmg.tags || []).includes('embrace')), '…and deals arts damage');
|
||||
h.run(1.05);
|
||||
assert.ok(!g.trait.doll && g.form === null && g.trait.dollSwitch, 'switching back after 1 s + 20 s');
|
||||
assert.ok(fxForm(fx('swap')[0]) === null, 'the fx gives the 本体 its model back');
|
||||
assert.equal(g.s.blockCnt, 2);
|
||||
approx(g.hp, g.s.maxHp, 'full HP again');
|
||||
h.run(DOLL_SWITCH + 1.5);
|
||||
assert.ok(log.attacks > atk0, 'the 本体 attacks again');
|
||||
assert.ok(g.skill.sp > sp, 'SP again');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('B: S2 生存的渴望 — a lethal hit during it leaves 1 HP (不死), its end switches her to the 替身 at once (no lethal HP loss, no knockout fx)', { skip }, () => {
|
||||
for (const hit of [true, false]) {
|
||||
const { h, g, lethal, fx } = setup({ sp: 999 });
|
||||
assert.ok(h.runUntil(() => g.skill.active, 5), 'S2 cast');
|
||||
h.run(1);
|
||||
if (hit) {
|
||||
lethal();
|
||||
assert.ok(g.alive && !g.trait.doll && g.hp >= 1 && g.hp < 2, '不死: 1 HP, not the 替身');
|
||||
}
|
||||
const fatals = h.hooksOf('fatal').length;
|
||||
assert.ok(h.runUntil(() => !g.skill.active, 30));
|
||||
assert.ok(g.alive && g.trait.doll && g.form === 'doll', 'the 替身 when S2 ends');
|
||||
approx(g.hp, g.s.maxHp, 'at full HP');
|
||||
assert.equal(h.hooksOf('fatal').length, fatals, 'a switch, not a lethal HP loss');
|
||||
assert.equal(fx('knockout').length, 0);
|
||||
assert.equal(fx('substitute').length, 1);
|
||||
h.run(DOLL_SWITCH + 20.05);
|
||||
assert.ok(g.alive && !g.trait.doll, 'back to the 本体, alive');
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
|
||||
test('C: a second lethal hit while she is the 替身 knocks her out: the death clip first, then the model form back; the redeploy is the 本体', { skip }, () => {
|
||||
const { h, g, log, lethal } = setup();
|
||||
h.run(1);
|
||||
lethal();
|
||||
h.run(DOLL_SWITCH / 2);
|
||||
lethal();
|
||||
assert.ok(g.alive && g.trait.doll, 'not during the switch animation (无敌)');
|
||||
h.run(DOLL_SWITCH);
|
||||
lethal();
|
||||
assert.equal(g.alive, false, 'knocked out');
|
||||
assert.ok(!g.trait.doll && g.form === null);
|
||||
h.step();
|
||||
const ev = h.events.filter((e) => (e[0] === 'die' && e[1] === g.id) || (e[0] === 'fx' && e[4]?.id === g.id && fxForm(e) !== undefined));
|
||||
assert.deepEqual(ev.slice(-2).map((e) => (e[0] === 'die' ? 'die' : `${e[1]}:${fxForm(e)}`)), ['die', 'dollEnd:null'], 'its 替身 death clip plays, then the form resets');
|
||||
assert.ok(h.b.redeploy(g, { free: true }), 'redeployed');
|
||||
assert.ok(g.alive && !g.trait.doll && g.form === null && g.s.blockCnt === 2, 'the 本体');
|
||||
const n = log.attacks;
|
||||
h.run(2);
|
||||
assert.ok(log.attacks > n, 'and she attacks');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('D: SP near full or full when she switches ⇒ no skill cast as the 替身 (S2 DEFAULT, S1 技能范围); cast again as the 本体', { skip }, () => {
|
||||
// S2 38 / 40: 阻回 holds it under the cost; after the switch back it fills and S2 is cast
|
||||
{
|
||||
const { h, g, log, lethal } = setup({ sp: 38 });
|
||||
h.run(0.5);
|
||||
lethal();
|
||||
h.run(DOLL_SWITCH + 20 + DOLL_SWITCH + 5);
|
||||
assert.deepEqual(log.castsAsDoll, [], 'no S2 as the 替身');
|
||||
assert.ok(log.casts.length === 1 && log.casts[0] > 0.5 + 2 * DOLL_SWITCH + 20, 'S2 after the switch back');
|
||||
assert.ok(g.alive);
|
||||
}
|
||||
// S1 (SKILL_RANGE) ready with no enemy around, then an enemy steps next to the 替身: no cast until she is back
|
||||
{
|
||||
const { h, g, log, lethal } = setup({ skillIndex: 0, sp: 999, enemy: false });
|
||||
h.run(0.5);
|
||||
assert.ok(g.skill.ready && !g.skill.active && log.casts.length === 0, 'S1 ready, nothing in its range');
|
||||
lethal();
|
||||
h.run(DOLL_SWITCH + 0.5);
|
||||
h.spawn('enemy_dummy', { pos: [9, 6] });
|
||||
h.run(15);
|
||||
assert.deepEqual(log.castsAsDoll, [], 'no S1 as the 替身 (silenced with its disarm) [ASSUMED]');
|
||||
assert.ok(g.skill.ready, 'still ready');
|
||||
h.run(10);
|
||||
assert.equal(log.casts.length, 1, 'cast once she is the 本体 again');
|
||||
assert.equal(log.castsAsDoll.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('S1 / S3 running at a lethal hit end with the switch (清除自身一切Buff); the content hook switches her at once, once', { skip }, () => {
|
||||
const { h, g, lethal } = setup({ skillIndex: 2, sp: 999 });
|
||||
const full = g.base.maxHp * (1 + g.findBuff('ghost2:abyss').mods.hpPct); // 阿戈尔的深邃 on herself
|
||||
assert.ok(h.runUntil(() => g.skill.active, 5), 'S3 cast');
|
||||
assert.ok(g.s.maxHp > full * 2, 'S3 max HP +');
|
||||
lethal();
|
||||
assert.ok(g.trait.doll && !g.skill.active, 'S3 over');
|
||||
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'substitute');
|
||||
approx(g.s.maxHp, full, 'no S3 max HP on the 替身');
|
||||
approx(g.hp, full, 'at its full HP');
|
||||
const s2 = setup();
|
||||
const c = s2.h.b.emit('dollSwitch', { unit: s2.g, reason: 'test', done: false });
|
||||
assert.ok(c.done && s2.g.trait.doll && s2.g.form === 'doll');
|
||||
assert.equal(s2.h.b.emit('dollSwitch', { unit: s2.g, reason: 'test', done: false }).done, false, 'already the 替身');
|
||||
});
|
||||
|
||||
test('风丸: her 替身 still attacks (PRTS "<替身>状态下可对空"); the same 替身 form, switch and 阻回', { skip }, () => {
|
||||
const id = 'chess_char_2_11_a';
|
||||
const { h, g, log, lethal } = setup({ id, skillIndex: 0 });
|
||||
h.run(1);
|
||||
lethal();
|
||||
assert.ok(g.trait.doll && g.form === 'doll' && g.s.flags.noSp);
|
||||
h.run(DOLL_SWITCH + 5);
|
||||
assert.ok(log.attacksAsDoll > 0, 'the 替身 attacks');
|
||||
assert.ok(!g.s.flags.silence && !g.s.flags.disarm);
|
||||
});
|
||||
@@ -253,14 +253,16 @@ test('librator module (玛恩纳 elite init_atk): +100 % ATK right after deploym
|
||||
approx(n.unit('chess_char_5_19_a').s.atk, n.unit('chess_char_5_19_a').base.atk, 1e-6);
|
||||
});
|
||||
|
||||
test('dollkeeper without a substitute token (归溟幽灵鲨) uses half its own HP, not 风丸\'s 纸偶', { skip: !hasGeneratedData() }, () => {
|
||||
test('dollkeeper without a substitute token (归溟幽灵鲨) keeps its own max HP, not 风丸\'s 纸偶', { skip: !hasGeneratedData() }, () => {
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }], content: 'none', autoFinish: false, timeLimit: 30 });
|
||||
h.step();
|
||||
const u = h.unit('chess_char_5_13_a');
|
||||
const full = u.s.maxHp;
|
||||
h.b.dealDamage(null, u, { amount: 1e7, type: 'true' });
|
||||
assert.ok(u.alive && u.hasBuff('trait:substitute'));
|
||||
approx(u.s.maxHp, full * 0.5, 1e-6);
|
||||
// PRTS 分支特性信息 傀儡师 "重设自身生命至最大值"; no 替身 of hers in character_table [ASSUMED: her own max HP]
|
||||
approx(u.s.maxHp, full, 1e-6);
|
||||
approx(u.hp, full, 1e-6);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -159,21 +159,39 @@ test('geek loses HP over time; merchant drains DP and retreats when broke; charg
|
||||
approx(h3.b.getPlayer('p1').dp, 13);
|
||||
});
|
||||
|
||||
test('dollkeeper swaps to a substitute on fatal damage and swaps back after 20 s', () => {
|
||||
test('dollkeeper: fatal damage ⇒ a 1 s switch, 20 s as the substitute (block 0, 阻回), the switch back; the substitute dies ⇒ the unit dies', () => {
|
||||
const h = makeBattle({ defs: { chess: { t_dollkeeper: mk('dollkeeper', 'SPECIAL', { stats: { maxHp: 2000, blockCnt: 2 } }) } }, units: [{ chessId: 't_dollkeeper', row: 9, col: 5 }], content: 'none' });
|
||||
h.step();
|
||||
const u = h.unit('t_dollkeeper');
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
assert.equal(u.alive, true, 'substitute instead of death');
|
||||
assert.equal(u.s.blockCnt, 0);
|
||||
assert.ok(u.trait.doll);
|
||||
h.run(20.1);
|
||||
assert.equal(u.trait.doll, false);
|
||||
approx(u.hp, 2000);
|
||||
assert.equal(u.s.blockCnt, 2);
|
||||
assert.ok(u.trait.doll && u.trait.dollSwitch, 'the switch animation');
|
||||
assert.equal(u.form, 'doll');
|
||||
approx(u.hp, 2000); // no 替身 token: its own max HP
|
||||
// the switch animation (PRTS 分支特性信息 傀儡师): 无敌, 不死 (a 流失 stops at 1 HP), 阻回, 眩晕 immunity
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
approx(u.hp, 2000); // 无敌
|
||||
assert.equal(h.b.applyStatus(u, 'stun', { duration: 5 }), false, '眩晕 immune');
|
||||
h.b.loseHp(u, 1e5);
|
||||
assert.ok(u.alive && u.trait.doll && u.hp >= 1, '不死');
|
||||
u.hp = 2000;
|
||||
h.run(1.05);
|
||||
assert.ok(u.trait.doll && !u.trait.dollSwitch, 'fighting as the substitute');
|
||||
h.run(19.9);
|
||||
assert.ok(u.trait.doll && u.s.flags.noSp, 'the 20 s form, 阻回');
|
||||
h.run(0.1);
|
||||
assert.ok(!u.trait.doll && u.trait.dollSwitch && u.form === null, 'switching back');
|
||||
assert.equal(u.s.blockCnt, 2, 'blocks again from the start of the switch back');
|
||||
approx(u.hp, 2000);
|
||||
h.run(1.05);
|
||||
assert.ok(!u.trait.dollSwitch && !u.s.flags.noSp, 'the body again, no 阻回');
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
h.run(1.05);
|
||||
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
|
||||
assert.equal(u.alive, false, 'substitute dies ⇒ unit dies');
|
||||
assert.ok(!u.trait.doll && u.form === null, 'the redeploy is the body again');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('phalanx never attacks until its skill; librator ramps ATK while idle and resets on skill end', () => {
|
||||
|
||||
Reference in new issue
Block a user