feedback1 WD: knock-out forms audit — leaders' 重生 clips, 守墓石像 modes

- reborn() tells the model its forms ('reborn' then 'form2' on the existing fx):
  锏 and 扎罗 hold their revive clips and switch to their B_* set (both used to
  stay on the A model and die on B_Die), “复仇者” plays Revive_Begin/Loop/End,
  杰斯顿 its C1_Die then the C2_* killer form.
- 守墓石像 per its PRTS talent: melee only while blocked; the first defeat is an
  instant 重生 into a 10 s unblockable, immobile, 失衡/浮空-immune statue (Sleep
  clip), then a flyer with ranged arts attacks that skip flyers, without a
  second HP refill.
- A form kind the skeleton has no clip set for (arts barrier, broken charge)
  no longer resets a model's form.
This commit is contained in:
sganggs committed 2026-10-03 02:01:35 +08:00
1 parent 92282878d3
commit 73e8bdcd87
7 files changed
+175 -23

No files matched your search

+3 -1
View File
@@ -230,7 +230,9 @@ a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
**Knock-outs that are not deaths** (`content/enemies.js`; player reports after 0.1.0): a `killed` ability that keeps the
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
wait for the real death, the only one with a `die` event. 重生 / form changes: `reborn()` (first knock-out → second
form), `husk()` (talent Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted and beaten; 假想敌:
再生's 傀儡 is unblockable; a husk still standing after `Revive[Trigger].interval` s stands up again with full HP),
转译基底·α (its original form cancels every damage instance; the 4th physical / arts instance or a block starts a 2 s
+5 -4
View File
@@ -90,7 +90,7 @@ import { GEO, ANIM, UF } from '../../../shared/constants.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView } from './units.js';
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
@@ -1421,13 +1421,14 @@ export async function createFieldView(host, options = {}) {
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1; 转译基底's forms, a 逐火 ember and its
// revival — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries, switches the view's
// clip set (UnitView.setForm); the info keeps it for a view built later
// revival, the leaders' 重生 — user report after 0.1.0): a 'phase' kind, or the `form` any other fx carries,
// switches the view's clip set (UnitView.setForm, a kind without a clip set of that skeleton changes nothing);
// the info keeps it for a view built later
const ex4 = e[4] && typeof e[4] === 'object' && e[4].id != null ? e[4] : null;
const form = !ex4 ? undefined : e[1] === 'phase' ? ex4.kind : 'form' in ex4 ? ex4.form : undefined;
if (form !== undefined) {
const inf = infos.get(ex4.id);
if (inf) inf.form = typeof form === 'string' ? form : null;
if (inf && (typeof form !== 'string' || FORMS[inf.spine || inf.defId]?.[form])) inf.form = typeof form === 'string' ? form : null;
views.get(ex4.id)?.setForm?.(form);
}
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
+29 -4
View File
@@ -108,8 +108,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* clip: it never attacks) or 特战术师 D_* — its manifest roles are the original form's A_Idle / A_Move;
* - 深池逐火战士 / 精锐战士 / 护卫 (#8): knocked out → 'Die' (the 1 s 重生), the 余烬 on Idle_2 / Move_2 and its death on
* Die_2; standing up again → 'Revive', then the warrior's manifest clips;
* - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips.
* 吉兆飞鳞's 晕眩模式 is its Stun clip already.
* - 假想敌:再生: knocked out → A_Die, the 傀儡 on B_Idle / B_Move / B_Die; back → B_Revive, then the A_* manifest clips;
* - the leaders' 重生 (sim reborn(): forms 'reborn' → 'form2'): 锏 Revive1, Revive2 held, Revive3, then B_*; 扎罗 A_revive_1 /
* _2 / _3, then B_* (its double-hit attack); “复仇者” Revive_Begin / _Loop / _End; 杰斯顿 C1_Die (its 4 s 重生), then
* C2_* — their manifests mix the forms (锏 / 扎罗 died on B_Die from the A model, the look of report #5);
* - 守墓石像 (forms 'stone' → 'fly'): the statue on Sleep [ASSUMED by name], then the flyer's *_2 clips.
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
*/
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
const clipSet = (idle, move, die, attack = null) => Object.freeze({
@@ -121,6 +125,16 @@ const EMBER = Object.freeze({
husk: Object.freeze({ change: 'Die', roles: clipSet('Idle_2', 'Move_2', 'Die_2') }),
revived: Object.freeze({ change: 'Revive', roles: Object.freeze({}) }),
});
/** A 重生 held on `hold` (also while the sim reports the rebirth's stun) after `begin`, then `end` and the second form. */
const rebirth = (begin, hold, end, form2 = Object.freeze({})) => Object.freeze({
reborn: Object.freeze({ change: begin, roles: Object.freeze({ idle: hold, deploy: hold, move: loop(hold), stun: loop(hold), attack: null, skill: null }) }),
form2: Object.freeze({ change: end, roles: form2 }),
});
const STATUE = Object.freeze({
stone: Object.freeze({ change: null, roles: Object.freeze({ idle: 'Sleep', deploy: 'Sleep', move: loop('Sleep'), stun: loop('Sleep'), attack: null, skill: null }) }),
fly: Object.freeze({ change: null, roles: clipSet('Idle_2', 'Move_2', 'Die_2', 'Attack_2') }),
});
const JAKILL2 = clipSet('C2_Idle', 'C2_Move', 'C2_Die', 'C2_Attack');
export const FORMS = Object.freeze({
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
@@ -137,6 +151,15 @@ export const FORMS = Object.freeze({
husk: Object.freeze({ change: 'A_Die', roles: clipSet('B_Idle', 'B_Move', 'B_Die') }),
revived: Object.freeze({ change: 'B_Revive', roles: Object.freeze({}) }),
}),
enemy_1525_blkswb: rebirth('Revive1', 'Revive2', 'Revive3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
enemy_1535_wlfmster: rebirth('A_revive_1', 'A_revive_2', 'A_revive_3', clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack')),
enemy_1539_reid: rebirth('Revive_Begin', 'Revive_Loop', 'Revive_End'),
enemy_1516_jakill: Object.freeze({
reborn: Object.freeze({ change: 'C1_Die', roles: JAKILL2 }),
form2: Object.freeze({ change: null, roles: JAKILL2 }),
}),
enemy_1172_dugago: STATUE,
enemy_1172_dugago_2: STATUE,
});
/**
@@ -363,12 +386,14 @@ export class UnitView {
}
/**
* The unit changed mode (sim fx 'phase' { id, kind }): the mode's clip set (FORMS) after its change clip; a kind with
* no clip set of its own goes back to the manifest clips. Kept for a model built later.
* The unit changed mode (sim fx 'phase' { id, kind } or another fx's `form`): the mode's clip set (FORMS) after its
* change clip; null goes back to the manifest clips; a kind this skeleton has no clip set for (an arts barrier, a
* broken charge …) changes nothing. Kept for a model built later.
*/
setForm(kind) {
const k = typeof kind === 'string' ? kind : null;
if (k === this.form) return;
if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return;
const had = !!this._formSpec();
this.form = k;
this.info.form = k;
+20 -12
View File
@@ -694,7 +694,8 @@ function freeAllPrisoners(b) {
* a rebirth of `dur` s — invulnerable, untargetable, released by its blocker, inert (no move / attack / skill).
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
* `a.state`: undefined → 'reborn' → 'form2'.
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
*/
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
const finish = (b, e, a) => {
@@ -707,7 +708,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
if (e.route) e.route.pts = null;
e.atkCd = 0;
b.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'reborn' });
b.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'reborn', form: 'form2' });
};
return {
killed(c, b, e, a) {
@@ -725,7 +726,7 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
onTick: during ? ({ battle }) => during(battle, e, a, battle.time - a.t0) : null,
onExpire: ({ battle }) => finish(battle, e, a),
});
b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id });
b.fx('telegraph', { x: e.x, y: e.y, r: 1, dur, kind: 'reborn', id: e.id, form: 'reborn' });
return true;
},
};
@@ -845,28 +846,35 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
};
}
/** 守墓石像: first knock-out → statue (DEF/RES up, immobile) → reborn as a flyer. */
/**
* 守墓石像 (PRTS 守墓石像 天赋): 地面模式 — melee attacks only while blocked; the first defeat is an instant 重生 to 100 % HP
* into 转换模式 for stone.duration s — unblockable, 自缚 (immobile), 失衡免疫, immune to 浮空, DEF +stone.def, RES
* +stone.magic_resistance, no attack [ASSUMED: PRTS lists none] — then 飞行模式: a flyer (失衡免疫) whose ranged attacks (the
* data's 1.6 radius) deal arts damage and never target flyers; the HP is not refilled again. fx forms 'stone' / 'fly'
* (its Sleep and *_2 clips, render/units.js FORMS). <破碎支柱> (an event device) has no counterpart in this mode.
*/
function statue(ab) {
const dur = T(ab, 'stone.duration') ?? 0;
return {
killed(c, b, e, a) {
spawn(b, e) { e.profile.melee = true; },
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;
b.fx('stone', { x: e.x, y: e.y, id: e.id, dur });
ab2.immune = new Set([...(ab2.immune || []), 'levitate']);
b.fx('stone', { x: e.x, y: e.y, id: e.id, dur, form: 'stone' });
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:liftoff', duration: 0.2, flags: { unblockable: true } }); // releases its blocker
Object.assign(e.profile, { noAttack: a.noAtk, melee: false, dmgType: 'arts', canTarget: (u) => !u.isFlying });
battle.addBuff(e, { key: 'ab:flight', persist: true, flags: { noDisplace: true } });
if (e.route) e.route.pts = null;
battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly' });
battle.fx('revive', { x: e.x, y: e.y, id: e.id, kind: 'fly', form: 'fly' });
},
});
return true;
@@ -2279,7 +2287,7 @@ export const KITS = Object.freeze({
enemy_1170_dushld_2: kitRefraction, // 深池重甲卫士队长 · refraction
enemy_1169_duphlx: kitPhalanx, // 深池方阵步兵 · refraction + DEF +200 per nearby same unit
enemy_1169_duphlx_2: kitPhalanx, // 深池方阵指挥官 · same
enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; 1st KO → statue 10 s → flyer
enemy_1172_dugago: (ab) => [...kitRefraction(ab), statue(ab)], // 守墓石像 · refraction; melee when blocked; 1st KO → unblockable statue 10 s → arts flyer
enemy_1172_dugago_2: (ab) => [...kitRefraction(ab), statue(ab)], // 愤怒的守墓石像 · same
enemy_1174_duholy: kitHolyGuard, // 深池伙友卫队 · refraction; ASPD-down field next to 影刃 (taunt from data)
enemy_1174_duholy_2: kitHolyGuard, // 深池伙友卫队精英 · same
+18 -2
View File
@@ -648,17 +648,33 @@ for (const key of ['enemy_1169_duphlx', 'enemy_1169_duphlx_2']) {
}
for (const key of ['enemy_1172_dugago', 'enemy_1172_dugago_2']) {
test(`${nm(key)}: first knock-out ⇒ statue (DEF +${tb(key, 'stone.def')}) for ${tb(key, 'stone.duration')} s, then reborn as a flyer`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }] });
test(`${nm(key)}: melee only while blocked; first knock-out ⇒ unblockable statue (DEF +${tb(key, 'stone.def')}, 失衡 / 浮空 immune) for ${tb(key, 'stone.duration')} s, then a flyer with ranged arts attacks that skip flyers (PRTS 天赋)`, () => {
// 地面模式: an operator in its 1.6 radius but not blocking it is never attacked
const h0 = arena({ units: [{ chessId: 't_gun', row: 11, col: 7 }] });
h0.step();
put(h0, key, [10, 7]);
h0.run(6);
assert.equal(h0.unit('t_gun').stats.taken, 0, 'no ranged attack in its ground mode');
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_gun', row: 11, col: 7 }], kits: { t_gun: NOATK }, hooks: ['damaged'], captureNoisy: true });
h.step();
const e = put(h, key, [10, 7]);
h.step();
killed(h, e, null);
assert.ok(e.alive);
assert.equal(e.hp, e.s.maxHp, 'an instant 重生 to full HP');
assert.equal(e.s.def, E[key].stats.def + tb(key, 'stone.def'));
h.step();
assert.ok(e.s.flags.unblockable && e.s.flags.noDisplace && !e.blockedBy, '转换模式: 无法被阻挡, 失衡免疫');
assert.equal(h.b.applyStatus(e, 'levitate', { duration: 2 }), false, '免疫浮空');
h.b.dealDamage(h.unit('t_gun'), e, { amount: e.s.maxHp * 0.3, type: 'true' });
const hp = e.hp;
h.run(tb(key, 'stone.duration') + 0.5);
assert.equal(e.motion, 'FLY');
assert.ok(!e.blockedBy);
assert.equal(e.hp, hp, 'no second refill when it takes off');
assert.ok(h.runUntil(() => e.stats.attacks > 0, 10), '飞行模式 attacks at range');
assert.ok(h.unit('t_gun').stats.taken + h.unit('t_wall').stats.taken > 0, 'an operator in its radius');
assert.ok(h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).every((c) => c.dmg.type === 'arts'), 'arts damage');
killed(h, e, null);
assert.ok(!e.alive, 'second knock-out is final');
});
+61
View File
@@ -212,6 +212,67 @@ describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', ()
});
});
describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms after report #5)', () => {
const REBIRTH = {
enemy_1525_blkswb: ['Revive1', 'Revive2', 'Revive3', 'B_Idle', 'B_Die'],
enemy_1535_wlfmster: ['A_revive_1', 'A_revive_2', 'A_revive_3', 'B_Idle', 'B_Die'],
enemy_1539_reid: ['Revive_Begin', 'Revive_Loop', 'Revive_End', 'Idle', 'Die'],
};
for (const [id, [begin, hold, end, idle2, die2]] of Object.entries(REBIRTH)) {
test(`${id}: knock-out ⇒ ${begin}, ${hold} held through the 重生's stun, ${end}, then ${idle2}; dies on ${die2}`, async () => {
const anims = assets.enemies[id].spine.animations;
for (const name of [begin, hold, end, idle2, die2]) assert.ok(name in anims, name);
const v = await enemy(id);
v.sync(sample(UF.STUNNED, ANIM.STUN), 1);
v.setForm('reborn');
assert.equal(clip(v), begin);
frames(v, Math.ceil(anims[begin] * 60) + 5);
assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned');
v.sync(sample(0, ANIM.IDLE), 6);
v.setForm('form2');
assert.equal(clip(v), end);
frames(v, Math.ceil(anims[end] * 60) + 5);
assert.equal(clip(v), idle2);
v.die();
assert.equal(clip(v), die2);
});
}
test('enemy_1516_jakill 杰斯顿: C1_Die is its 4 s 重生, then the killer form C2_*', async () => {
const v = await enemy('enemy_1516_jakill');
v.setForm('reborn');
assert.equal(clip(v), 'C1_Die');
frames(v, 245);
v.setForm('form2');
v.sync(sample(0, ANIM.MOVE), 5);
assert.equal(clip(v), 'C2_Move');
v.onAttack(null, 5.5);
assert.equal(clip(v), 'C2_Attack');
});
test('enemy_1172_dugago 守墓石像: the statue on Sleep, the flyer on *_2', async () => {
const v = await enemy('enemy_1172_dugago');
v.setForm('stone');
v.sync(sample(), 1);
assert.equal(clip(v), 'Sleep');
v.setForm('fly');
v.sync(sample(UF.FLYING, ANIM.MOVE), 2);
assert.equal(clip(v), 'Move_2');
v.die();
assert.equal(clip(v), 'Die_2');
});
test('a kind without a clip set of that skeleton (an arts barrier, a broken charge) keeps the current form', async () => {
const v = await enemy('enemy_10081_mpplai');
v.setForm('translator_fuchou');
frames(v, 130);
v.setForm('artsBarrier');
assert.equal(v.form, 'translator_fuchou');
v.sync(sample(), 3);
assert.equal(clip(v), 'B_Idle');
});
});
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/);
+39
View File
@@ -211,6 +211,45 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
});
});
// ---------------------------------------------------------------------------------------------------------------
// audit: every other knock-out form of the mode ends in a targetable, killable unit and tells the model its clip set
describe('audit: the leaders\' 重生 and 守墓石像 — no vanishing, killable afterwards, the model follows', () => {
const CASES = [
['enemy_1525_blkswb', 'Reborn.duration', 'Reborn.invincible', 'reborn', 'form2'],
['enemy_1535_wlfmster', 'Reborn.duration', 'Passive2.invincible_time', 'reborn', 'form2'],
['enemy_1539_reid', 'Reborn.duration', 'Reborn.invincible', 'reborn', 'form2'],
['enemy_1516_jakill', 'reborn.duration', null, 'reborn', 'form2'],
['enemy_1517_xi', 'reborn.duration', 'reborn.invincible', 'reborn', 'form2'],
['enemy_1512_mcmstr', 'reborn.duration', null, 'reborn', 'form2'],
['enemy_10098_crhro', 'reborn.duration', 'reborn.invincible', 'reborn', 'form2'],
['enemy_1172_dugago', 'stone.duration', null, 'stone', 'fly'],
['enemy_1172_dugago_2', 'stone.duration', null, 'stone', 'fly'],
];
for (const [key, durKey, invKey, f1, f2] of CASES) {
test(`${key} ${E[key].name}: knock-out ⇒ form '${f1}' (no die event, no kill), then '${f2}' — targetable and killed for good by the next knock-out`, () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, key, { pos: [10, 7], mods: { speedMul: 0 } });
const gun = h.unit('t_gun');
h.b.kill(e, gun);
assert.ok(e.alive, 'not removed');
const fxOf = (form) => h.eventsOf('fx').some((ev) => ev[4] && ev[4].id === e.id && ev[4].form === form);
assert.ok(fxOf(f1), `fx form '${f1}' at the knock-out`);
assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 0, 'no die event');
assert.equal(h.b.killed, 0, 'no kill');
h.run((tb(key, durKey) ?? 0) + (invKey ? tb(key, invKey) ?? 0 : 0) + 0.3);
assert.ok(e.alive && fxOf(f2), `fx form '${f2}' when it stands up`);
assert.ok(!e.s.flags.untargetable && !e.s.flags.invulnerable, 'targetable and vulnerable again');
if (!e.s.flags.stealth && !e.isFlying) assert.ok(canTargetEnemy(gun, e, gun.profile), 'a ranged operator can target it');
h.b.kill(e, gun);
assert.ok(!e.alive && e.removeReason === 'killed', 'the second knock-out is its death');
assert.equal(h.b.killed, 1);
assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 1);
});
}
});
// ---------------------------------------------------------------------------------------------------------------
// #5 转译基底·α