Files
Convey123 ffa538c39d feedback7 R22-RENDER: 赤刃明霄陈 S3's sword wave is drawn along its path — the sim sends its position as a chen3Wave fx every 0.12 s, the client draws a 'qi' blade (PR #382 by @Convey123, owner item 12); golden: diy-073 / diy-074 events.fx and fx.chen3Wave only
The 赤霄·天喟 wave is a kit-managed probe (it turns corners and lives as long as the skill), not a snapshot
projectile, and the sim emitted one `dash` at the cast and a `slash` per hit — nothing along the way, so the client
drew no wave. op-chen3.js now emits fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id }) every WAVE_FX_EVERY (0.12 s;
4 ticks), the trail restarting at a corner; render/fx registers chen3Wave → 'qi' (pt: drawn at the event, never on
her) and draws a streak plus a crescent head turned the way it travels. The look is a procedural stand-in, not the
official particle prefab.

Substantially @Convey123's code (PR #382), authored as theirs. Changed on review: the head is projected after
streak() — streak() reuses the effect system's projection scratch, so the PR drew the crescent on the trail's start,
one event behind the wave — and the trail, head and heading share one height; an unused test variable dropped.

Golden: diy-073 (5_diy*=chen3 S3 in two slots) events.fx 508 → 1558, fx.chen3Wave → 1050; diy-074 (6_diy*=chen3 S3)
events.fx 272 → 872, fx.chen3Wave → 600. HP, kills, damage, hooks and snapshot hashes unchanged: battle.fx only
records an event.

Tests: test/content/op_chen3.test.js (+1: the events, their cadence, id, direction, trail start, silence after the
skill) 12/12; test/render/fxchen3wave.test.js (new, 6: the archetype, drawn at the wave not at her, the head on the
wave and turned with it, a sweep joining into one trail, junk input, nothing left after) 6/6; boardart 15/15.
2026-10-08 18:48:53 +08:00

133 lines
7.2 KiB
JavaScript

// test/render/fxchen3wave.test.js — 赤刃明霄陈 S3 赤霄·天喟 的「龙剑气」(用户报告: 三技能的龙剑气看不到).
//
// The wave is a kit-managed probe in the sim (server/sim/content/kits/ops/op-chen3.js): it leaves her tile forward at
// 1.5 tiles/s, turns 90° at the field's edge / high ground / gates, and lives as long as the skill. It is not in the
// snapshot's projectile list, so the client can only draw it from `fx` events — and before this change the sim sent
// none for the wave itself (one `dash` puff at the cast, one `slash` spark per enemy hit): the 龙剑气 was invisible.
// The sim now emits `fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id, skill })` every 0.12 s — its position, the
// previous point (the trail) and its direction (the crescent's heading) — and render/fx.js draws the 'qi' archetype.
// Against the headless fake PIXI (test/render/fakepixi.js), like fxflame.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
function makeFx(views, ts = 1) {
const P = fake.P;
const ctx = fakeViewCtx(P);
const map = new Map(views.map((v) => [v.id, v]));
const fx = new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
/** One sim event of the wave: its position, the previous point and the direction (`dc` cols, `dr` rows). */
const wave = (fx, id, x, y, fromX, fromY, dc = 1, dr = 0) => fx.simFx('chen3Wave', x, y, { x, y, fromX, fromY, dr, dc, id, skill: 'chen3:wave' });
/** Screen box of the drawn trail/head: min/max of every live particle position. */
const drawn = (fx) => {
const list = fx.parts.filter((p) => p.sp && p.sp.visible !== false);
if (!list.length) return null;
const xs = list.map((p) => p.sp.position.x), ys = list.map((p) => p.sp.position.y);
return { n: list.length, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
};
describe('赤刃明霄陈 S3 龙剑气: 波自己会被画出来 (issue: 看不到)', () => {
test('its own archetype — a travelling blade, not the generic sparkle or a dust puff', () => {
assert.equal(FX.FX_KINDS.chen3Wave.a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').pt, true, 'drawn at the wave (never anchored to the caster)');
});
test('one event draws a trail and a crescent head at the wave, not at her', () => {
const her = unit(1, 3, 10), waveAt = { x: 8, y: 10 };
const { fx } = makeFx([her]);
assert.equal(drawn(fx), null, 'nothing before the event');
wave(fx, her.id, waveAt.x, waveAt.y, 7.8, 10);
run(fx, 1 / 60);
const box = drawn(fx);
assert.ok(box && box.n >= 2, `the trail and the head are drawn (${box && box.n} pieces)`);
const at = cam.project(waveAt.x, waveAt.y, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
const hers = cam.project(her.x, her.y, 0.5, { x: 0, y: 0, s: 0, depth: 0 });
const near = (v, t) => Math.abs(v - t) <= 90;
assert.ok(near(box.x1, at.x) || near(box.x0, at.x), `drawn around the wave (${Math.round(box.x0)}…${Math.round(box.x1)} vs ${Math.round(at.x)})`);
assert.ok(Math.abs(box.x1 - hers.x) > 60, 'not at her tile');
});
// streak() reuses the effect system's projection scratch: a head placed from it sat on the trail's start, one event
// (~0.2 tile) behind the wave
test('the crescent head sits on the wave itself (not on the trail\'s start) and points the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
const heads = () => fx.parts.filter((p) => p.sp.visible !== false && p.sp.texture === fx.tex.slash);
wave(fx, her.id, 8, 10, 7.6, 10, 1, 0); // moving right (+col)
const right = heads();
assert.equal(right.length, 1, 'one crescent head');
const at = cam.project(8, 10, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
assert.ok(Math.abs(right[0].x - at.x) < 0.5 && Math.abs(right[0].y - at.y) < 0.5,
`head at the wave (${right[0].x.toFixed(1)}, ${right[0].y.toFixed(1)}) vs (${at.x.toFixed(1)}, ${at.y.toFixed(1)})`);
assert.ok(Math.abs(right[0].sp.rotation - Math.PI / 2) < 0.35, `bulge to the right (rot ${right[0].sp.rotation.toFixed(2)})`);
run(fx, 1);
wave(fx, her.id, 8, 10, 8, 9.6, 0, 1); // moving up the screen (+row: the far side)
const up = heads();
assert.equal(up.length, 1);
assert.ok(Math.abs(up[0].sp.rotation) < 0.35, `bulge up (rot ${up[0].sp.rotation.toFixed(2)})`);
});
test('the events of a sweep join into one trail that goes the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
let prev = 3;
const fronts = [];
for (let i = 0; i < 8; i++) { // 0.12 s apart at 1.5 tiles/s ⇒ 0.18 tile a step
const x = 3 + 0.18 * (i + 1);
wave(fx, her.id, x, 10, prev, 10);
prev = x;
run(fx, 0.12);
fronts.push(drawn(fx).x1);
}
const box = drawn(fx);
assert.ok(box && box.n >= 4, `the recent steps are still alive (${box && box.n} pieces)`);
// the drawn front walks forward with the wave (each event is drawn ahead of the last, the older ones still fading)
for (let i = 1; i < fronts.length; i++) {
assert.ok(fronts[i] > fronts[i - 1] - 1, `step ${i}: the front moved on (${Math.round(fronts[i - 1])} → ${Math.round(fronts[i])})`);
}
assert.ok(fronts[fronts.length - 1] - fronts[0] > 60, `the sweep crossed the field (${Math.round(fronts[0])} → ${Math.round(fronts[fronts.length - 1])} px)`);
});
test('a wave boxed in (no movement) and a junk extra never throw', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 8, 10, 0, 0); // standing still: no direction
wave(fx, her.id, 8, 10, undefined, undefined);
fx.simFx('chen3Wave', 8, 10, { id: her.id }); // bare event
fx.simFx('chen3Wave', NaN, NaN, { id: her.id, dr: 1, dc: 0 });
run(fx, 0.2);
assert.ok(fx.counts.particles > 0, 'still drawn');
});
test('the whole thing is gone once the events stop', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 7.8, 10);
run(fx, 0.05);
assert.ok(drawn(fx).n > 0);
run(fx, 1); // every trail/head particle expires (life 0.34 s max)
assert.equal(drawn(fx), null, 'nothing is left hanging on the field');
});
});