diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 0796e434..4319e8d5 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -101,7 +101,12 @@ export class FxSim { else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true); break; } - case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break; + // 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a + // stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the + // unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field + case 'chill': + if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); } + break; case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break; case 'healAoe': { this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6); diff --git a/public/js/render/fx/zones.js b/public/js/render/fx/zones.js index 3dcad273..a6ad4f86 100644 --- a/public/js/render/fx/zones.js +++ b/public/js/render/fx/zones.js @@ -180,11 +180,32 @@ export class FxZones { this.tintSprite.tint = tint; } - /** A short flurry of snow over the field (cold wind). */ - snowfall(tint) { + /** + * Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the + * bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the + * community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind + * with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts, + * speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an + * operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before. + */ + snowfall(tint, gust = false) { const size = this.ctx.screenSize(); - for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) { - this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); + const W = size.width, H = size.height, low = this.quality === 'low'; + if (!gust) { + for (let i = 0; i < (low ? 10 : 26); i++) { + this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); + } + return; + } + // snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust + for (let i = 0; i < (low ? 22 : 48); i++) { + const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1); + this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 }); + } + // gust streaks: thin wind lines racing up through the field along their velocity + for (let i = 0; i < (low ? 4 : 10); i++) { + const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12); + this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) }); } } diff --git a/test/render/fxchill.test.js b/test/render/fxchill.test.js new file mode 100644 index 00000000..d44da581 --- /dev/null +++ b/test/render/fxchill.test.js @@ -0,0 +1,60 @@ +// test/render/fxchill.test.js — the 'chill' fx of coldWind (render/fx/simfx.js + zones.js snowfall), PR #386 by +// @leiming2333: 谢拉格's 盟约寒风 blows a blizzard UP the field (the official direction, the owner's confirmation of +// 2026-10-08) with a stronger cold flash; an operator's own coldWind (灵知 S3, 麦哲伦 S1's pulse every 3 s — emitted with +// the unit's `id`) keeps the light downward flurry and the softer flash. Against the headless fake PIXI +// (test/render/fakepixi.js), like fxchen3wave.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 W = 1600, H = 900; +const cam = presetCamera('normal', { width: W, height: H }); + +function makeFx(quality = 'high') { + const P = fake.P; + const ctx = fakeViewCtx(P); + const unit = { id: 7, x: 5, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} }; + return new FX.FxSystem({ + P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true }, + timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: (id) => (id === unit.id ? unit : null), + screenSize: () => ({ width: W, height: H }), fieldTop: () => 120, + }); +} +const live = (fx) => fx.parts.filter((p) => p.sp && p.sp.visible !== false); + +describe('coldWind: 谢拉格\'s 盟约寒风 blows up the field, an operator\'s own cold keeps the light flurry', () => { + test('the bond\'s gust (the device emits no anchor unit): 48 flakes and 10 streaks rising from the bottom, the stronger flash', () => { + const fx = makeFx(); + // server/sim/content/devices.js startColdWind: the field centre, the player, the cold stacks, the cold duration + fx.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 }); + const ps = live(fx); + assert.equal(ps.length, 58, '48 flakes + 10 streaks at quality high'); + assert.ok(ps.every((p) => p.vy < 0), 'every particle rises (screen y grows downward)'); + assert.ok(ps.every((p) => p.vx > 0), 'on a crosswind'); + assert.ok(ps.every((p) => p.y >= H * 0.55 - 1e-9), 'lifted off the lower part of the screen (some start below it)'); + assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 10, 'gust streaks'); + assert.deepEqual([fx.tintA, fx.tintDur], [0.45, 1]); + const low = makeFx('low'); + low.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 }); + assert.equal(live(low).length, 26, 'quality low: 22 flakes + 4 streaks'); + }); + + test('an operator\'s own coldWind (灵知 S3, 麦哲伦\'s pulse: `id` = the unit): the light flurry drifting down, the softer flash', () => { + const fx = makeFx(); + fx.simFx('coldWind', 5, 10, { x: 5, y: 10, id: 7, n: 2 }); + const ps = live(fx); + assert.equal(ps.length, 26); + assert.ok(ps.every((p) => p.vy > 0), 'falling as before'); + assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 0, 'no gust streaks'); + assert.deepEqual([fx.tintA, fx.tintDur], [0.35, 0.8]); + }); +});