diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 4eaa406d..1c474b5e 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -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; diff --git a/public/js/render/fx/zones.js b/public/js/render/fx/zones.js index bb9fc917..3dcad273 100644 --- a/public/js/render/fx/zones.js +++ b/public/js/render/fx/zones.js @@ -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). */ diff --git a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js index 59cd281b..b0312bcd 100644 --- a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js +++ b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js @@ -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 diff --git a/test/render/feedback5-zone-stack.test.js b/test/render/feedback5-zone-stack.test.js new file mode 100644 index 00000000..1b57a2ec --- /dev/null +++ b/test/render/feedback5-zone-stack.test.js @@ -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})`); +});