// public/js/render/fx/arrivals.js — FxSystem deploy, promotion, death, crates and pops. // Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). import { COLORS, DMG_STYLE } from '../style.js'; import { SKILL_GOLD, easeOut } from './limits.js'; export class FxArrivals { deploy(view) { if (!view) return; const p = this._proj(view.x, view.y, view.z || 0); const s = p.s; const col = view.isEnemy ? 0xff6a5a : 0x9ff0dc; this.particle('pillar', p.x, p.y, { tint: col, life: 0.45, s0: s / 64 * 0.7, s1: s / 64 * 0.2, a0: 0.9, a1: 0, sx: 1, ay: 1 }); this.ring(view.x, view.y, view.z || 0, 0.1, 0.8, col, 0.45); this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' }); } /** * Promotion (精锐晋升, a merge) at the elite's spot — the board tile of the consumed copy it replaced (PRTS 卫戍协议/ * 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置") or its bench slot: a gold pillar with a white core, a shockwave * and a hex ring on the ground, rising motes; `from` = world points of the other consumed copies (gold streaks from * them to the elite). Counted in `promotions` (render/app.js setPrep; tests). * @param {any} view * @param {Array<{ x: number, y: number, z?: number }>} [from] */ promote(view, from = []) { if (!view) return; this.promotions = (this.promotions || 0) + 1; const z = view.z || 0; const g = this._proj(view.x, view.y, z, this._g); const gx = g.x, gy = g.y, s = g.s; for (const f of from) if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) this.streak(f.x, f.y, view.x, view.y, Math.max(z, f.z || 0) + 0.3, SKILL_GOLD, 0.45); this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.8, s0: (s / 64) * 1.1, s1: (s / 64) * 1.4, a0: 0.95, a1: 0, sx: 0.85, ay: 1 }); this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.42, s0: (s / 64) * 0.9, s1: (s / 64) * 1.2, a0: 0.9, a1: 0, sx: 0.3, ay: 1 }); this.ring(view.x, view.y, z, 0.15, 1.6, 0xffe7a0, 0.5, 'shock'); this.ring(view.x, view.y, z, 0.3, 1.2, SKILL_GOLD, 0.7, 'hex'); const c = this._chest(view, this._q); this.burst(c.x, c.y, s, this.rich ? 12 : 5, 0xffe28a, { speed: 1.5, up: 1.8, life: 0.8, tex: 'dot', size: 0.36 }); } death(view) { if (!view) return; const p = this._chest(view); const s = p.s; const col = view.isEnemy ? 0xff7a52 : 0xbfeee2; const n = this.quality === 'low' ? 5 : 12; for (let i = 0; i < n; i++) { this.particle(i % 3 ? 'dot' : 'shard', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.3) * s * 0.6, { tint: col, vx: (Math.random() - 0.5) * s * 0.6, vy: -s * (0.4 + Math.random() * 0.8), drag: 1.5, life: 0.7 + Math.random() * 0.5, s0: s / 32 * 0.12, s1: 0, a0: 0.9, a1: 0, spin: (Math.random() - 0.5) * 6, }); } this.particle('smoke', p.x, p.y, { add: false, tint: 0x1a1a1a, life: 0.8, s0: s / 128 * 0.6, s1: s / 128 * 1.4, a0: 0.5, a1: 0 }); } crateBreak(x, y, z, tint = null) { const p = this._proj(x, y, (z || 0) + 0.35); const s = p.s; for (let i = 0; i < (this.quality === 'low' ? 4 : 10); i++) { this.particle('shard', p.x, p.y, { add: false, tint: tint ?? (i % 2 ? 0xc89a5a : 0x9a6d38), vx: (Math.random() - 0.5) * s * 2.2, vy: -s * (0.6 + Math.random()), g: s * 4, life: 0.6, s0: s / 32 * 0.25, s1: s / 32 * 0.15, a0: 1, a1: 0.2, spin: (Math.random() - 0.5) * 14, }); } this.particle('smoke', p.x, p.y, { add: false, tint: 0x8a7a60, life: 0.7, s0: s / 128 * 0.6, s1: s / 128 * 1.5, a0: 0.45, a1: 0 }); } /** Screen-space pop (bond layer gain / bounty coins). `icon` = texture or null. */ pop(icon, label, tint, i = 0) { const P = this.P; const size = this.ctx.screenSize(); const top = this.ctx.fieldTop ? this.ctx.fieldTop() : size.height * 0.2; const c = new P.Container(); const x = size.width / 2 + (i % 5 - 2) * 70; c.position.set(x, top); if (icon) { const glow = new P.Sprite(this.tex.glow); glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9); const sp = new P.Sprite(icon); sp.anchor.set(0.5); sp.tint = tint; // 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until // its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's // 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then. const fit = () => { if (sp.destroyed) return; sp.scale.set(46 / Math.max(1, icon.width, icon.height)); sp.visible = true; }; if (icon.valid) fit(); else { sp.visible = false; icon.once('update', fit); } c.addChild(glow, sp); } if (label) { const t = new P.BitmapText(label, { fontName: DMG_STYLE.heal.font, fontSize: 26 }); t.anchor.set(0, 0.5); t.position.set(26, 0); if (tint === COLORS.gold) t.tint = 0xffe066; c.addChild(t); } this.ctx.layers.screen.addChild(c); this.pops.push({ c, t: 0, dur: 1.4, y0: top }); if (this.pops.length > 12) { const o = this.pops.shift(); o.c.destroy({ children: true }); } } _updatePops(dt) { let w = 0; for (const p of this.pops) { p.t += dt; if (p.t >= p.dur) { p.c.destroy({ children: true }); continue; } const k = p.t / p.dur; p.c.position.y = p.y0 - 40 * easeOut(k); p.c.alpha = k < 0.15 ? k / 0.15 : k > 0.7 ? 1 - (k - 0.7) / 0.3 : 1; const s = k < 0.15 ? 0.6 + (k / 0.15) * 0.5 : 1.1 - Math.min(0.1, (k - 0.15)); p.c.scale.set(s); this.pops[w++] = p; } this.pops.length = w; } }