feedback5 CB2: persistent ground areas stay readable when they stack — a zone re-sent under one key updates in place, persistent areas are dimmer and share their light where they overlap (community report of 2026-10-06 「炼金单元等范围持续性技能特效太亮(尤其是多层叠加以后)」)

Cause: 引星棘刺 S2's drifting alchemy unit is re-sent every second (position and radius change) and the client drew each
event as a new additive zone lasting the rest of the unit's life — up to twelve layers on one spot; overlapping units
(锡人's charges, several alchemists) added up the same way (render/fx/zones.js: disc alpha 0.28, edge 0.75, ADD blend).
Now: the sim fx carries `key` (kits/ops/chess_char_5_15-thorn2.js) and zones.js updates the zone under that key in place;
zoneAlpha draws a persistent area (> 3 real s) at 0.16 / 0.5 and ÷ √n among the n persistent areas overlapping it (centres
closer than ¾ of their radii summed); short bursts and telegraphs unchanged.

Golden: unchanged (fx events are counted by kind; the snapshots do not carry fx).
Tests: test/render/feedback5-zone-stack.test.js (3, fail on the parent).
This commit is contained in:
sganggs committed 2026-10-07 02:32:28 +08:00
1 parent 17c96843ee
commit f1e462ef62
4 files changed
+130 -11

No files matched your search

+2 -1
View File
@@ -84,7 +84,8 @@ export class FxSim {
this.mortar(this._viewOf(ex.id ?? ex.src), at.x, at.y, r, flight);
break;
}
case 'zone': this.zone(at.x, at.y, at.z, r, col, Math.max(0.6, dur || 1.5), spec.tex); break;
// `key`: one zone the sim re-sends as it moves / grows (an update in place, never a second layer — zones.js)
case 'zone': this.zone(at.x, at.y, at.z, r, col, Math.max(0.6, dur || 1.5), spec.tex, false, ex.key != null ? String(ex.key) : null); break;
case 'wall': {
// a line of burning tiles through the anchor tile along `axis` ('col' | 'row', from the sim event)
const rect = this.ctx.fieldRect ? this.ctx.fieldRect() : null;
+47 -7
View File
@@ -3,6 +3,29 @@
import { NO_OPTS, clamp, easeOut } from './limits.js';
/** A zone lasting longer than this (real s) is a persistent area (炼金单元, fields): drawn dimmer than a skill's short burst. */
export const ZONE_PERSIST = 3;
/** Disc / edge alpha: a short zone, a persistent one (before the overlap damping), a telegraph. */
export const ZONE_ALPHA = Object.freeze({ short: [0.28, 0.75], persist: [0.16, 0.5], warn: [0.35, 0.75] });
/**
* [disc, edge] alpha of zone `zn` among `zones` (additive blending). A persistent area is dimmer and shares its light with
* the persistent areas overlapping it — ÷ √n for n of them, centres closer than ¾ of their radii summed —, so stacked
* alchemy units read as one brighter area, not a white blot (community report of 2026-10-06 「炼金单元等范围持续性技能特效太亮
* (尤其是多层叠加以后)」). Short bursts and telegraphs keep their full strength.
*/
export function zoneAlpha(zn, zones) {
if (zn.warn) return ZONE_ALPHA.warn;
if (!(zn.dur > ZONE_PERSIST)) return ZONE_ALPHA.short;
let n = 1;
for (const o of zones) {
if (o === zn || o.warn || !(o.dur > ZONE_PERSIST)) continue;
if (Math.hypot(o.x - zn.x, o.y - zn.y) < 0.75 * (o.r + zn.r)) n++;
}
const k = 1 / Math.sqrt(n);
return [ZONE_ALPHA.persist[0] * k, ZONE_ALPHA.persist[1] * k];
}
export class FxZones {
/**
* An explosion on the ground at (x, y, z) of radius r tiles: fireball in `col`, a white flash, a shockwave and a
@@ -65,15 +88,28 @@ export class FxZones {
this.burst(g.x, g.y - g.s * 0.2, g.s, 6, tint, { speed: 2.4, life: 0.35 });
}
/** Persistent ground area: soft disc + pulsing edge ring for `dur` real seconds (telegraphs pulse faster). */
zone(x, y, z, r, tint, dur, tex = 'soft', warn = false) {
/**
* Persistent ground area: soft disc + pulsing edge ring for `dur` real seconds (telegraphs pulse faster). `key` (the
* sim fx's `key`): a zone already up under that key is updated in place — moved, resized, its end `dur` from now —
* instead of a second one stacked on it (引星棘刺 S2's drifting alchemy unit is re-sent every second: twelve layers of the
* same zone used to pile up, community report of 2026-10-06 「炼金单元等范围持续性技能特效太亮(尤其是多层叠加以后)」).
*/
zone(x, y, z, r, tint, dur, tex = 'soft', warn = false, key = null) {
if (key != null) {
const zn = this.zones.find((q) => q.key === key && q.t < q.dur);
if (zn) {
zn.x = x; zn.y = y; zn.z = z; zn.r = r; zn.tint = tint; zn.dur = zn.t + dur;
this._onGround(zn.disc, y, z); this._onGround(zn.edge, y, z);
return;
}
}
const P = this.P;
const disc = new P.Sprite(this.tex[tex === 'ring' ? 'soft' : tex] || this.tex.soft);
disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.tint = tint;
const edge = new P.Sprite(this.tex.ring);
edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.tint = tint;
this._onGround(disc, y, z); this._onGround(edge, y, z);
this.zones.push({ disc, edge, x, y, z, r, t: 0, dur, warn });
this.zones.push({ disc, edge, x, y, z, r, t: 0, dur, warn, key, tint });
if (this.zones.length > 24) this._freeZone(this.zones.shift());
}
@@ -86,6 +122,10 @@ export class FxZones {
for (const zn of this.zones) {
zn.t += dt;
if (zn.t >= zn.dur) { this._freeZone(zn); continue; }
this.zones[w++] = zn;
}
this.zones.length = w;
for (const zn of this.zones) {
const k = zn.t / zn.dur;
const grow = Math.min(1, zn.t / 0.25);
const rad = zn.r * (0.35 + 0.65 * easeOut(grow));
@@ -94,11 +134,11 @@ export class FxZones {
const rx = p.s * rad, ry = Math.max(1, p.y - q.y);
const fade = k > 0.8 ? (1 - k) / 0.2 : 1;
const pulse = zn.warn ? 0.55 + 0.45 * Math.abs(Math.sin(zn.t * 7)) : 0.8 + 0.2 * Math.sin(zn.t * 3);
zn.disc.position.set(p.x, p.y); zn.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); zn.disc.alpha = (zn.warn ? 0.35 : 0.28) * fade * pulse;
zn.edge.position.set(p.x, p.y); zn.edge.scale.set((rx * 2.1) / 128, (ry * 2.1) / 128); zn.edge.alpha = 0.75 * fade * pulse;
this.zones[w++] = zn;
const [discA, edgeA] = zoneAlpha(zn, this.zones);
zn.disc.tint = zn.edge.tint = zn.tint ?? zn.disc.tint;
zn.disc.position.set(p.x, p.y); zn.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); zn.disc.alpha = discA * fade * pulse;
zn.edge.position.set(p.x, p.y); zn.edge.scale.set((rx * 2.1) / 128, (ry * 2.1) / 128); zn.edge.alpha = edgeA * fade * pulse;
}
this.zones.length = w;
}
/** Flash a set of tiles ([[r,c]]) on the ground (telegraphed boxes, blast tiles). */
@@ -135,9 +135,12 @@ export default {
const extra = battle.alliesInGrid(unit).some((a) => a !== unit && isOp(a)) ? Math.min(num(t0.projectile_extend), num(t0.projectile_extend_max, Infinity)) : 0;
// it drifts away from her deployment tile ("移动方向始终为远离棘刺部署位置中心的方向")
const dx = t.x - unit.x, dy = t.y - unit.y, len = Math.hypot(dx, dy) || 1;
const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra };
// `key`: the fx re-sent every second as the unit drifts and grows is this one zone — the client updates it in
// place instead of stacking a new layer each second (render/fx/zones.js; community report of 2026-10-06)
const key = `thorn2:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`;
const z = { x: t.x, y: t.y, vx: (dx / len) * speed, vy: (dy / len) * speed, t: 0, acc: 0, dur: baseDur + extra, key };
(unit.mem.zones ??= []).push(z);
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur });
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: r0, duration: z.dur, key });
},
},
talents: [
@@ -184,7 +187,7 @@ export default {
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] });
const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr);
if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true });
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t) });
battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t), key: z.key });
}
}
for (const [e, steps] of seaHits) { // S3 debuff: 不叠加 — the strongest zone wins
+75
View File
@@ -0,0 +1,75 @@
// test/render/feedback5-zone-stack.test.js — persistent ground areas (炼金单元 …) stay readable when they stack (community
// report of 2026-10-06 「炼金师分支干员的炼金单元等范围持续性技能特效太亮(尤其是多层叠加以后)」): a zone the sim re-sends under one
// `key` (引星棘刺 S2's drifting unit, every second) is updated in place instead of piling up a new additive layer each time,
// and a persistent area is drawn dimmer and shares its light with the persistent areas overlapping it (÷ √n) —
// render/fx/zones.js zone / zoneAlpha, render/fx/simfx.js (the event's `key`).
// Run: node --test test/render/feedback5-zone-stack.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem } from '../../public/js/render/fx.js';
import { zoneAlpha, ZONE_ALPHA, ZONE_PERSIST } from '../../public/js/render/fx/zones.js';
import { presetCamera } from '../../public/js/render/projection.js';
/** An FxSystem shell with fake sprites: enough for zone() / _updateZones() / simFx('zone'). */
function fxShell() {
const cam = presetCamera('normal', { width: 1280, height: 720 });
class Sprite {
constructor() { this.anchor = { set() {} }; this.position = { set() {} }; this.scale = { set() {} }; this.alpha = 1; this.tint = 0; this.destroyed = false; }
destroy() { this.destroyed = true; }
}
const fx = Object.create(FxSystem.prototype);
Object.assign(fx, {
P: { Sprite, BLEND_MODES: { ADD: 1 } },
tex: { soft: {}, ring: {} },
zones: [],
ctx: { cam: () => cam, heightAt: () => 0, timeScale: () => 2, view: () => null },
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 },
_onGround() {},
_groundZ: () => 0,
});
return fx;
}
test('a zone re-sent under the same key is moved / resized in place — one layer, not one per event', () => {
const fx = fxShell();
fx.simFx('zone', 5, 10, { id: 1, r: 1.1, duration: 12, key: 'thorn2:7:1' });
for (let i = 1; i <= 11; i++) {
fx._updateZones(0.5);
fx.simFx('zone', 5 + 0.1 * i, 10, { id: 1, r: 1.1 + 0.13 * i, duration: 12 - i, key: 'thorn2:7:1' });
}
assert.equal(fx.zones.length, 1, 'twelve events, one zone');
const zn = fx.zones[0];
assert.ok(Math.abs(zn.x - 6.1) < 1e-9 && Math.abs(zn.r - (1.1 + 0.13 * 11)) < 1e-9, 'it follows the last event (drift, growth)');
assert.ok(Math.abs(zn.dur - (zn.t + Math.max(0.6, 1 / 2))) < 1e-9, 'its end: the remaining game second at 2× (at least 0.6 s) from now');
fx.simFx('zone', 8, 10, { id: 1, r: 1, duration: 12, key: 'thorn2:7:2' });
fx.simFx('zone', 8, 11, { id: 2, r: 2, duration: 11 });
fx.simFx('zone', 8, 11, { id: 2, r: 2, duration: 11 });
assert.equal(fx.zones.length, 4, 'another key, and unkeyed zones (锡人\'s units, one event each), are zones of their own');
});
test('persistent areas are dimmer and share their light when they overlap; short bursts and telegraphs keep theirs', () => {
const zone = (x, dur = 6, r = 2, warn = false) => ({ x, y: 10, r, dur, warn });
const alone = zone(2);
assert.deepEqual(zoneAlpha(alone, [alone]), ZONE_ALPHA.persist);
const stack = [zone(5), zone(5.2), zone(5.4), zone(5.6)];
const [disc, edge] = zoneAlpha(stack[0], stack);
assert.ok(Math.abs(disc - ZONE_ALPHA.persist[0] / 2) < 1e-12 && Math.abs(edge - ZONE_ALPHA.persist[1] / 2) < 1e-12, 'four on one spot: ÷ √4 each');
assert.ok(4 * disc <= 2 * ZONE_ALPHA.short[0], `the four discs together (${(4 * disc).toFixed(2)}) stay within two short bursts`);
const far = [zone(2), zone(9)];
assert.deepEqual(zoneAlpha(far[0], far), ZONE_ALPHA.persist, 'apart: no damping');
const short = zone(5, ZONE_PERSIST - 0.5);
assert.deepEqual(zoneAlpha(short, [short, ...stack]), ZONE_ALPHA.short, 'a short burst keeps its strength');
const warn = zone(5, 6, 2, true);
assert.deepEqual(zoneAlpha(warn, [warn, ...stack]), ZONE_ALPHA.warn, 'a telegraph keeps its strength');
});
test('the renderer applies it: four alchemy units on one spot draw their discs at half the lone strength', () => {
const fx = fxShell();
for (let i = 0; i < 4; i++) fx.simFx('zone', 5 + 0.1 * i, 10, { id: 9, r: 2, duration: 11 });
fx.simFx('zone', 15, 10, { id: 9, r: 2, duration: 11 });
fx._updateZones(1); // past the 0.25 s growth, before the fade
const lone = fx.zones[4], stacked = fx.zones.slice(0, 4);
const ratio = stacked[0].disc.alpha / lone.disc.alpha;
assert.ok(Math.abs(ratio - 0.5) < 1e-9, `÷ √4 (${ratio})`);
});