feedback1 WD: a form change clip plays out; browser check of the forms
- render/spine.js: no attack (wind-up or 'atk') cuts a form's change clip short; a knocked-out 逐火 no longer reports its last attack in the snapshot. - test/render/feedback1-forms.browser.test.js (opt-in RENDER_E2E=1): the real sim in headless Chrome on 战场#01 — 转译基底·α A_* → A_Die_B → B_* → B_Die, a 深池逐火战士 knocked out in front of its blocker: Die → the 隐匿 ember on Idle_2, beaten → Die_2. - PLAYING.md: the 余烬 and 转译基底 rules for players.
This commit is contained in:
1 parent
73e8bdcd87
commit
06be81f67e
4 files changed
+159
-3
No files matched your search
+1
-1
@@ -73,7 +73,7 @@
|
||||
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
|
||||
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
||||
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡时才能被选为攻击目标:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),要用干员挡住它才能打掉,10 秒内没打掉就满血站起来;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
||||
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
|
||||
|
||||
## 5. 联防(同盟)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// die() die clip once (callers fade out afterwards)
|
||||
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
|
||||
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
|
||||
// (no attack cuts the change clip short)
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
|
||||
|
||||
@@ -212,7 +213,7 @@ export class SpineActor {
|
||||
* false when it is still too early (call again next frame) or there is nothing to wind up.
|
||||
*/
|
||||
windUp(interval, lead) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || !(lead >= 0)) return false;
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
if (this.mode === 'attack' && this.current === clip.loop) return false; // in rhythm: attack() re-phases
|
||||
@@ -232,7 +233,7 @@ export class SpineActor {
|
||||
|
||||
/** An attack happened now. `interval` = seconds between attacks (game time already scaled to real). */
|
||||
attack(interval) {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die') return;
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change') return; // a form change plays out
|
||||
this.interval = clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return;
|
||||
|
||||
@@ -833,6 +833,7 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke
|
||||
a.noAtk = e.profile.noAttack;
|
||||
a.max = a.max ?? e.base.maxHp;
|
||||
e.profile.noAttack = true;
|
||||
e.lastAttackAt = -Infinity; // the snapshot shows no attack of the fallen warrior
|
||||
setHits(e, hits);
|
||||
hitCount(b, e, true);
|
||||
b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } });
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// test/render/feedback1-forms.browser.test.js — player reports after 0.1.0 (#5, #8) in headless Chrome: the real sim runs
|
||||
// in the page (the client runner's loadBrowserSim) and feeds the render demo's field view on 战场#01 (render/app.js fx
|
||||
// 'phase' / `form` → render/units.js setForm / FORMS):
|
||||
// #5 转译基底·α walks on its A_* clips, takes 4 physical hits without losing HP, plays its 2 s A_Die_B change and goes on
|
||||
// as 寻仇者 (B_*), dying on B_Die — it used to die in its first form, on B_Die ("加载变身动画然后就没了");
|
||||
// #8 a 深池逐火战士 knocked out in front of 百炼嘉维尔 plays 'Die' (its 1 s 重生), stands there as the 隐匿 ember on
|
||||
// Idle_2 (blocked, so its blocker beats it) and dies on Die_2 — it used to stay an untargetable ember forever.
|
||||
//
|
||||
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1-forms.browser.test.js
|
||||
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1-*.png.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildBattleSpec } from '../../server/sim/spec.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUT = path.join(ROOT, 'test/e2e/out');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
|
||||
/** The view plays the feed a little behind the sim (its interpolation buffer): game seconds allowed after an event. */
|
||||
const LAG = 0.4;
|
||||
|
||||
/** 百炼嘉维尔 facing the lower gate on (9,8) (`row`, `col`: elsewhere); one enemy walking the lower lane (9,10) → (9,2). */
|
||||
const spec = (enemyKey, id, row = 9, col = 8) => buildBattleSpec({
|
||||
battleId: `e2e.${id}`, fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 3, stageId: 'act2autochess_m01', timeLimit: 400,
|
||||
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_4_23_b', row, col, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
|
||||
spawns: [{ time: 0, enemyKey, routeIndex: 0, count: 1, mods: { hpMul: 1, atkMul: 0.01 } }],
|
||||
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Run `spec` in the page for `secs` game seconds; `act(b, e, ally)` runs every tick (the test's own interventions).
|
||||
* Returns samples { t, clip, form, alive, hp, stealth, blocked } of the enemy's view every 6 ticks.
|
||||
*/
|
||||
async function runInPage(page, port, s, enemyKey, secs, actSrc) {
|
||||
await page.goto(`http://127.0.0.1:${port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
|
||||
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
|
||||
return page.evaluate(async (spec, key, secs, actSrc) => {
|
||||
const act = new Function('b', 'e', 'ally', 'state', actSrc);
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { data } = await import('/js/data.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
|
||||
const v = window.__demo.view;
|
||||
v.setStage(data.lookup('stages', spec.stageId));
|
||||
b.step();
|
||||
const meta = b.fieldMeta();
|
||||
v.enterBattle(meta);
|
||||
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
|
||||
v.setLocalFeed({ on: true, speed: 2 });
|
||||
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
|
||||
const out = [];
|
||||
const state = {};
|
||||
let e = null;
|
||||
for (let i = 0; i < 30 * secs; i++) {
|
||||
if (!b.finished) b.step(); // (the view goes on after the end: the death clip)
|
||||
const t = Math.max(b.time, (i + 1) / 30);
|
||||
e = e ?? b.units.find((x) => x.side === 'enemy' && x.defId === key) ?? null;
|
||||
const ally = b.allyUnits[0];
|
||||
if (e) act(b, e, ally, state);
|
||||
if (i % 3 === 2) {
|
||||
const ev = b.drainEvents();
|
||||
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
|
||||
v.pushSnapshot(b.snapshot());
|
||||
await raf();
|
||||
if (i === 30 * 2) await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
|
||||
const view = e && v.debug.views.get(e.id);
|
||||
if (e && i % 6 === 5) out.push({ t: +t.toFixed(2), clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady, alive: e.alive, hp: e.hp, max: e.s.maxHp, stealth: !!e.s.flags.stealth, blocked: !!e.blockedBy });
|
||||
}
|
||||
}
|
||||
return { out, state };
|
||||
}, s, enemyKey, secs, actSrc);
|
||||
}
|
||||
|
||||
describe('player reports after 0.1.0: the models follow the knock-out forms (headless Chrome, real sim)', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function page() {
|
||||
const p = await browser.newPage();
|
||||
const problems = [];
|
||||
p.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
p.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
p.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
await p.setViewport({ width: 1280, height: 720 });
|
||||
return { p, problems };
|
||||
}
|
||||
|
||||
test('#5 转译基底·α: A_* while 4 physical hits do nothing, A_Die_B, then 寻仇者 B_*; it dies on B_Die', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
// the operator stands off its lane (no block); at 3 s: 4 physical hits from it (the 4th starts the change); at 9 s:
|
||||
// knocked out for good
|
||||
const log = await runInPage(p, srv.port, spec('enemy_10081_mpplai', 'translator', 11, 4), 'enemy_10081_mpplai', 12, `
|
||||
if (!state.hit && b.time >= 3) { state.hit = b.time; state.hp = e.hp; for (let i = 0; i < 4; i++) b.dealDamage(ally, e, { amount: 1e6, type: 'phys' }); state.after = e.hp; }
|
||||
if (!state.kill && b.time >= 9 && e.alive) { state.kill = b.time; b.kill(e, ally); }
|
||||
`);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-translator.png') });
|
||||
const { out, state } = log;
|
||||
assert.equal(state.after, state.hp, 'the 4 hits took no HP');
|
||||
const pre = out.filter((x) => x.spine && x.t < state.hit);
|
||||
assert.ok(pre.length > 0 && pre.every((x) => /^A_/.test(x.clip)), `first form: A_* (${[...new Set(pre.map((x) => x.clip))]})`);
|
||||
const change = out.filter((x) => x.t > state.hit + LAG && x.t < state.hit + 1.8);
|
||||
assert.ok(change.length > 0 && change.every((x) => x.clip === 'A_Die_B' && x.alive), `the change clip (${[...new Set(change.map((x) => x.clip))]})`);
|
||||
const form = out.filter((x) => x.t > state.hit + 2 + LAG && x.t < state.kill);
|
||||
assert.ok(form.length > 0 && form.every((x) => /^B_/.test(x.clip) && x.form === 'translator_fuchou'), `寻仇者 clips (${[...new Set(form.map((x) => x.clip))]})`);
|
||||
const dead = out.filter((x) => x.t > state.kill + LAG);
|
||||
assert.ok(dead.length > 0 && dead.every((x) => !x.alive && x.clip === 'B_Die'), `dies on B_Die (${[...new Set(dead.map((x) => x.clip))]})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('#8 深池逐火战士 knocked out in front of 百炼嘉维尔: Die (重生), the 隐匿 ember on Idle_2, beaten, Die_2', async () => {
|
||||
const { p, problems } = await page();
|
||||
try {
|
||||
const log = await runInPage(p, srv.port, spec('enemy_1288_duskls', 'ember'), 'enemy_1288_duskls', 16, `
|
||||
if (!state.ko && e.blockedBy && b.time >= 6) { state.ko = b.time; b.kill(e, ally); }
|
||||
if (state.ko && !state.dead && !e.alive) state.dead = b.time;
|
||||
`);
|
||||
await p.screenshot({ path: path.join(OUT, 'feedback1-ember.png') });
|
||||
const { out, state } = log;
|
||||
assert.ok(state.ko, 'blocked and knocked out');
|
||||
const pre = out.filter((x) => x.spine && x.t < state.ko);
|
||||
assert.ok(pre.length > 0 && pre.every((x) => !/_2$/.test(x.clip)), `the warrior's clips (${[...new Set(pre.map((x) => x.clip))]})`);
|
||||
const reb = out.filter((x) => x.t > state.ko + LAG && x.t < state.ko + 0.9);
|
||||
assert.ok(reb.length > 0 && reb.every((x) => x.clip === 'Die' && x.alive), `重生: 'Die' (${[...new Set(reb.map((x) => x.clip))]})`);
|
||||
assert.ok(state.dead > state.ko + 1, `the blocker beats the ember (dead at ${state.dead})`);
|
||||
const ember = out.filter((x) => x.t > state.ko + 1 + LAG && x.t < state.dead);
|
||||
assert.ok(ember.length > 0 && ember.every((x) => x.clip === 'Idle_2' && x.stealth && x.max === 5), `the 隐匿 ember on Idle_2 (${[...new Set(ember.map((x) => x.clip))]})`);
|
||||
assert.ok(ember.some((x) => x.hp < 5), 'its hit counter runs down');
|
||||
const dead = out.filter((x) => x.t > state.dead + LAG);
|
||||
assert.ok(dead.length > 0 && dead.every((x) => x.clip === 'Die_2'), `dies on Die_2 (${[...new Set(dead.map((x) => x.clip))]})`);
|
||||
assert.deepEqual(problems, []);
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user