- Change all big screen backgrounds to pure Chinese red (#c41230) - Remove vignette effect for consistent display on different screens - Unify all prize data to match prizes.json configuration - Add demo participants fallback when no data imported - Fix particle visibility reset in startGalaxy() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
268 lines
7.0 KiB
TypeScript
268 lines
7.0 KiB
TypeScript
import { Application, Container, Graphics, Ticker } from 'pixi.js';
|
|
|
|
// Cinematic color palette
|
|
const COLORS = {
|
|
wineRed: 0xc41230, // Festive bright Chinese red
|
|
gold: 0xf0c239,
|
|
goldDark: 0xd4a84b,
|
|
goldLight: 0xffd700,
|
|
};
|
|
|
|
interface Particle {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
size: number;
|
|
alpha: number;
|
|
rotation: number;
|
|
rotationSpeed: number;
|
|
type: 'dust' | 'symbol' | 'streak';
|
|
life: number;
|
|
maxLife: number;
|
|
}
|
|
|
|
export class BackgroundEffect {
|
|
private app: Application;
|
|
private container: Container;
|
|
private particles: Particle[] = [];
|
|
private dustLayer: Graphics;
|
|
private symbolLayer: Graphics;
|
|
private streakLayer: Graphics;
|
|
private time = 0;
|
|
private windOffset = 0;
|
|
private isDestroyed = false;
|
|
|
|
// Configuration
|
|
private readonly DUST_COUNT = 120;
|
|
private readonly SYMBOL_COUNT = 25;
|
|
private readonly STREAK_INTERVAL = 3000; // ms between streaks
|
|
|
|
constructor() {
|
|
this.app = new Application();
|
|
this.container = new Container();
|
|
this.dustLayer = new Graphics();
|
|
this.symbolLayer = new Graphics();
|
|
this.streakLayer = new Graphics();
|
|
}
|
|
|
|
async init(canvas: HTMLCanvasElement): Promise<void> {
|
|
await this.app.init({
|
|
canvas,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
backgroundColor: COLORS.wineRed,
|
|
antialias: true,
|
|
resolution: window.devicePixelRatio || 1,
|
|
autoDensity: true,
|
|
});
|
|
|
|
// Setup layers (back to front)
|
|
this.container.addChild(this.dustLayer);
|
|
this.container.addChild(this.symbolLayer);
|
|
this.container.addChild(this.streakLayer);
|
|
this.app.stage.addChild(this.container);
|
|
|
|
// Draw vignette overlay
|
|
this.drawVignette();
|
|
|
|
// Initialize particles
|
|
this.initDustParticles();
|
|
this.initSymbolParticles();
|
|
|
|
// Start animation loop
|
|
this.app.ticker.add(this.update.bind(this));
|
|
|
|
// Spawn streaks periodically
|
|
this.spawnStreakLoop();
|
|
|
|
// Handle resize
|
|
window.addEventListener('resize', this.handleResize.bind(this));
|
|
}
|
|
|
|
private drawVignette(): void {
|
|
// Disabled vignette for pure solid color background
|
|
// Keep method for potential future use
|
|
}
|
|
|
|
private initDustParticles(): void {
|
|
const w = this.app.screen.width;
|
|
const h = this.app.screen.height;
|
|
|
|
for (let i = 0; i < this.DUST_COUNT; i++) {
|
|
this.particles.push({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
vx: (Math.random() - 0.5) * 0.3,
|
|
vy: (Math.random() - 0.5) * 0.2,
|
|
size: Math.random() * 2 + 1,
|
|
alpha: Math.random() * 0.3 + 0.1,
|
|
rotation: 0,
|
|
rotationSpeed: 0,
|
|
type: 'dust',
|
|
life: 0,
|
|
maxLife: Infinity,
|
|
});
|
|
}
|
|
}
|
|
|
|
private initSymbolParticles(): void {
|
|
const w = this.app.screen.width;
|
|
const h = this.app.screen.height;
|
|
|
|
for (let i = 0; i < this.SYMBOL_COUNT; i++) {
|
|
this.particles.push({
|
|
x: Math.random() * w,
|
|
y: h + Math.random() * 100,
|
|
vx: (Math.random() - 0.5) * 0.5,
|
|
vy: -Math.random() * 0.8 - 0.3,
|
|
size: Math.random() * 8 + 4,
|
|
alpha: Math.random() * 0.4 + 0.2,
|
|
rotation: Math.random() * Math.PI * 2,
|
|
rotationSpeed: (Math.random() - 0.5) * 0.02,
|
|
type: 'symbol',
|
|
life: 0,
|
|
maxLife: Infinity,
|
|
});
|
|
}
|
|
}
|
|
|
|
private spawnStreak(): void {
|
|
if (this.isDestroyed) return;
|
|
|
|
const w = this.app.screen.width;
|
|
const h = this.app.screen.height;
|
|
const startX = Math.random() * w * 0.3;
|
|
const startY = Math.random() * h * 0.3;
|
|
|
|
this.particles.push({
|
|
x: startX,
|
|
y: startY,
|
|
vx: Math.random() * 8 + 6,
|
|
vy: Math.random() * 4 + 3,
|
|
size: Math.random() * 3 + 2,
|
|
alpha: 0.9,
|
|
rotation: Math.atan2(4, 8),
|
|
rotationSpeed: 0,
|
|
type: 'streak',
|
|
life: 0,
|
|
maxLife: 60, // frames
|
|
});
|
|
}
|
|
|
|
private spawnStreakLoop(): void {
|
|
if (this.isDestroyed) return;
|
|
|
|
this.spawnStreak();
|
|
setTimeout(() => this.spawnStreakLoop(), this.STREAK_INTERVAL + Math.random() * 2000);
|
|
}
|
|
|
|
private update(ticker: Ticker): void {
|
|
if (this.isDestroyed) return;
|
|
|
|
this.time += ticker.deltaTime;
|
|
this.windOffset = Math.sin(this.time * 0.01) * 0.5;
|
|
|
|
// Clear graphics
|
|
this.dustLayer.clear();
|
|
this.symbolLayer.clear();
|
|
this.streakLayer.clear();
|
|
|
|
const w = this.app.screen.width;
|
|
const h = this.app.screen.height;
|
|
|
|
// Update and draw particles
|
|
this.particles = this.particles.filter((p) => {
|
|
// Update position with wind
|
|
p.x += p.vx + this.windOffset * (p.type === 'dust' ? 0.3 : 0.1);
|
|
p.y += p.vy;
|
|
p.rotation += p.rotationSpeed;
|
|
p.life++;
|
|
|
|
// Check bounds and lifecycle
|
|
if (p.type === 'streak') {
|
|
if (p.life >= p.maxLife || p.x > w || p.y > h) {
|
|
return false;
|
|
}
|
|
p.alpha = 0.9 * (1 - p.life / p.maxLife);
|
|
} else if (p.type === 'symbol') {
|
|
// Reset symbols that go off top
|
|
if (p.y < -20) {
|
|
p.y = h + 20;
|
|
p.x = Math.random() * w;
|
|
}
|
|
} else {
|
|
// Wrap dust particles
|
|
if (p.x < 0) p.x = w;
|
|
if (p.x > w) p.x = 0;
|
|
if (p.y < 0) p.y = h;
|
|
if (p.y > h) p.y = 0;
|
|
}
|
|
|
|
// Draw particle
|
|
this.drawParticle(p);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private drawParticle(p: Particle): void {
|
|
const layer = p.type === 'dust'
|
|
? this.dustLayer
|
|
: p.type === 'symbol'
|
|
? this.symbolLayer
|
|
: this.streakLayer;
|
|
|
|
if (p.type === 'dust') {
|
|
// Simple gold circle
|
|
layer.circle(p.x, p.y, p.size);
|
|
layer.fill({ color: COLORS.gold, alpha: p.alpha });
|
|
} else if (p.type === 'symbol') {
|
|
// Fortune symbols (circles and squares)
|
|
layer.setTransform(p.x, p.y, 1, 1, p.rotation);
|
|
|
|
if (Math.random() > 0.5) {
|
|
// Circle
|
|
layer.circle(0, 0, p.size);
|
|
} else {
|
|
// Square/diamond
|
|
layer.rect(-p.size / 2, -p.size / 2, p.size, p.size);
|
|
}
|
|
layer.fill({ color: COLORS.goldDark, alpha: p.alpha });
|
|
layer.setTransform(0, 0, 1, 1, 0);
|
|
} else if (p.type === 'streak') {
|
|
// Gold streak with trail
|
|
const trailLength = 80;
|
|
const dx = Math.cos(p.rotation);
|
|
const dy = Math.sin(p.rotation);
|
|
|
|
// Draw gradient trail
|
|
for (let i = 0; i < 10; i++) {
|
|
const ratio = i / 10;
|
|
const tx = p.x - dx * trailLength * ratio;
|
|
const ty = p.y - dy * trailLength * ratio;
|
|
const ta = p.alpha * (1 - ratio) * 0.5;
|
|
const ts = p.size * (1 - ratio * 0.5);
|
|
|
|
layer.circle(tx, ty, ts);
|
|
layer.fill({ color: COLORS.goldLight, alpha: ta });
|
|
}
|
|
|
|
// Draw head
|
|
layer.circle(p.x, p.y, p.size);
|
|
layer.fill({ color: COLORS.goldLight, alpha: p.alpha });
|
|
}
|
|
}
|
|
|
|
private handleResize(): void {
|
|
if (this.isDestroyed) return;
|
|
|
|
this.app.renderer.resize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
destroy(): void {
|
|
this.isDestroyed = true;
|
|
window.removeEventListener('resize', this.handleResize.bind(this));
|
|
this.app.destroy(true, { children: true });
|
|
}
|
|
}
|