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.
This commit is contained in:
Convey123 authored and sganggs committed 2026-10-08 18:48:53 +08:00
1 parent ae22335cfe
commit ffa538c39d
6 files changed
+219 -8

No files matched your search

+33
View File
@@ -319,6 +319,39 @@ test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each at
}
});
// 用户报告: 「自选干员赤刃明霄陈三技能的龙剑气看不到」. 波是 kit 自己管的探针(会拐弯、跟技能同寿),不在快照的 projectile
// 列表里,所以客户端只能靠 fx 事件画它——原来只在起手发了一个 `dash`、每次命中发 `slash`,波本身**没有任何事件**。
// 现在每 WAVE_FX_EVERY(0.12) 秒发一次 `chen3Wave`:位置 + 上一个点(拖尾)+ 方向(月牙朝向)。
test('S3: the 龙剑气 streams its position to the client as `chen3Wave` fx (position, previous point, direction)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] }); // a target in range: the MANUAL skill casts
const waveFx = () => h.events.filter((e) => e[0] === 'fx' && e[1] === 'chen3Wave');
assert.ok(h.runUntil(() => u.skill.active, 2));
h.run(1);
const fx = waveFx();
assert.ok(fx.length >= 6, `每 0.12 秒一次 ⇒ 1 秒约 8 次(实际 ${fx.length})`);
for (const [, , x, y, ex] of fx) {
assert.equal(ex.id, u.id, '带着她的 id(可定位到玩家/队伍)');
assert.equal(ex.skill, 'chen3:wave');
assert.ok(Math.abs(x - u.tileC) <= 12 && Math.abs(y - u.tileR) <= 12, '波在场上');
}
// 位置随时间前移、方向与朝向一致(row 10 向右),from 是上一个点(拖尾)
const [, , x0, , ex0] = fx[0];
const last = fx[fx.length - 1];
assert.ok(last[2] > x0, `向前推进(${x0.toFixed(2)} → ${last[2].toFixed(2)})`);
assert.deepEqual([last[4].dc, last[4].dr], [1, 0], '方向 = 她的朝向(向右)');
assert.ok(Math.abs(ex0.fromX - u.tileC) < 0.5 && Math.abs(ex0.fromY - u.tileR) < 0.5, '第一个事件从上场点开始');
assert.ok(last[4].fromX < last[2], 'from 是上一个事件的点(拖尾向前)');
// 技能结束后不再发
h.runUntil(() => !u.skill.active, 25);
h.step();
const n = waveFx().length;
h.run(2);
assert.equal(waveFx().length, n, '技能结束后不再有波的 fx');
done(h);
});
test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
+4 -4
View File
@@ -3158,8 +3158,8 @@
"enemy_9010_acpupp": [39,6,33,47997,613287]
},
"hooks": [1,188,6098,435,435,1218,1333,444,85,174,21,21,0,18383,8,44,156,89,1,91,0,0,4],
"events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":508,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9},
"fx": {"blink":7,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4},
"events": {"atk":476,"deploy":30,"die":85,"dmg":1333,"engage":11,"fx":1558,"heal":7,"leak":89,"skill":42,"spawn":170,"status":9},
"fx": {"blink":7,"chen3Wave":1050,"dash":14,"dodge":4,"ember":6,"shield":138,"slash":308,"summon":21,"telegraph":6,"thorns":4},
"snaps": "f1faec58 cbf72c28 58e3f561 b8182f46 b1f5b0f5 1de38a22 bc7c2a2b f233c949 01d56108 560ee3fc 8ec7a4b9 b95b08ce 1943beb7 c593b60a e179c3a0 4a2ad117 171d3fbd 1e1c4411 efd84577 32339940 33fdecb2"
},
"diy-074": {
@@ -3197,8 +3197,8 @@
],
"enemies": {"enemy_1229_darmy":[27,7,20,398,133429],"enemy_1229_darmy_2":[57,14,43,11905,308752],"enemy_1275_dwlock_2":[21,6,15,50953,369503]},
"hooks": [1,133,6831,258,258,706,729,74,50,128,11,11,0,8921,19,29,105,78,1,50,0,2,24],
"events": {"atk":301,"deploy":28,"die":50,"dmg":729,"engage":12,"fx":272,"heal":1,"leak":78,"skill":22,"spawn":117,"status":9},
"fx": {"beam":46,"blink":4,"burst":2,"dash":8,"dodge":24,"shield":74,"slash":114},
"events": {"atk":301,"deploy":28,"die":50,"dmg":729,"engage":12,"fx":872,"heal":1,"leak":78,"skill":22,"spawn":117,"status":9},
"fx": {"beam":46,"blink":4,"burst":2,"chen3Wave":600,"dash":8,"dodge":24,"shield":74,"slash":114},
"snaps": "498914af 0a79facb 2cda1fb6 0a030d4c 9ce66676 179c11c1 62b416c3 fe75747c 619a4d17 62a8fa9d 86e914bd b1b84020 f6408a48 d11bff81 68c04d21 052d976c 5b0d0f6d 7b80bb08 e10c9b3e 0befc79d 41d43704 1e0376b1 8d64e6cf"
},
"diy-075": {
+132
View File
@@ -0,0 +1,132 @@
// 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');
});
});