feat: add Admin Control Panel, voting status check, and router security
Admin Control Panel: - Add full AdminControl.vue with 3 sections (Voting, Lottery, Global) - Add AdminLogin.vue with access code gate (20268888) - Add admin.ts store with state persistence - Add admin.types.ts with state machine types - Add router guards for /admin/director-console Voting System Fixes: - Add voting status check before accepting votes (VOTING_CLOSED error) - Fix client to display server error messages - Fix button disabled logic to prevent ambiguity in paused state - Auto-generate userId on connect to fix UNAUTHORIZED error Big Screen Enhancements: - Add LiveVotingView.vue with particle system - Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal) - Add useSocketClient.ts composable - Fix MainDisplay.vue SCSS syntax error - Add admin state sync listener in display store Server Updates: - Add admin.service.ts for state management - Add isVotingOpen() and getVotingStatus() methods - Add admin socket event handlers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
283
packages/client-screen/src/pixi/BackgroundEffect.ts
Normal file
283
packages/client-screen/src/pixi/BackgroundEffect.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Application, Container, Graphics, Ticker } from 'pixi.js';
|
||||
|
||||
// Cinematic color palette
|
||||
const COLORS = {
|
||||
wineRed: 0x2a0a0e,
|
||||
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 {
|
||||
const vignette = new Graphics();
|
||||
const w = this.app.screen.width;
|
||||
const h = this.app.screen.height;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const maxRadius = Math.sqrt(cx * cx + cy * cy);
|
||||
|
||||
// Create radial gradient effect with multiple circles
|
||||
for (let i = 20; i >= 0; i--) {
|
||||
const ratio = i / 20;
|
||||
const radius = maxRadius * (0.3 + ratio * 0.7);
|
||||
const alpha = ratio * 0.8;
|
||||
|
||||
vignette.circle(cx, cy, radius);
|
||||
vignette.fill({ color: 0x000000, alpha });
|
||||
}
|
||||
|
||||
this.app.stage.addChildAt(vignette, 0);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
752
packages/client-screen/src/pixi/LotteryMachine.ts
Normal file
752
packages/client-screen/src/pixi/LotteryMachine.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
import { Application, Container, Graphics, Text, TextStyle, Ticker, BlurFilter } from 'pixi.js';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const COLORS = {
|
||||
gold: 0xf0c239,
|
||||
goldLight: 0xffd700,
|
||||
goldDark: 0xd4a84b,
|
||||
red: 0xc21f30,
|
||||
redLight: 0xe63946,
|
||||
redDark: 0x8b0000,
|
||||
wineRed: 0x2a0a0e,
|
||||
black: 0x000000,
|
||||
white: 0xffffff,
|
||||
};
|
||||
|
||||
// Easing functions
|
||||
const Easing = {
|
||||
easeInOutCubic: (t: number): number => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
},
|
||||
easeOutQuart: (t: number): number => {
|
||||
return 1 - Math.pow(1 - t, 4);
|
||||
},
|
||||
easeInQuart: (t: number): number => {
|
||||
return t * t * t * t;
|
||||
},
|
||||
easeOutBack: (t: number): number => {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
},
|
||||
easeOutElastic: (t: number): number => {
|
||||
const c4 = (2 * Math.PI) / 3;
|
||||
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
zodiac?: string;
|
||||
age?: number;
|
||||
avatar?: string;
|
||||
isEligible?: boolean;
|
||||
}
|
||||
|
||||
export type LotteryPhase = 'idle' | 'galaxy' | 'storm' | 'reveal' | 'complete';
|
||||
|
||||
interface NameParticle {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number; // For 3D sphere effect
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
scale: number;
|
||||
alpha: number;
|
||||
isEligible: boolean;
|
||||
isWinner: boolean;
|
||||
text: Text | null;
|
||||
}
|
||||
|
||||
interface ConfettiParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: number;
|
||||
alpha: number;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LotteryMachine Class
|
||||
// ============================================================================
|
||||
|
||||
export class LotteryMachine {
|
||||
private app: Application;
|
||||
private mainContainer: Container;
|
||||
private galaxyContainer: Container;
|
||||
private stormContainer: Container;
|
||||
private revealContainer: Container;
|
||||
private confettiContainer: Container;
|
||||
private scrollContainer: Container;
|
||||
private dimOverlay: Graphics;
|
||||
|
||||
private nameParticles: NameParticle[] = [];
|
||||
private confettiParticles: ConfettiParticle[] = [];
|
||||
private participants: Participant[] = [];
|
||||
private winners: Participant[] = [];
|
||||
|
||||
private phase: LotteryPhase = 'idle';
|
||||
private phaseTime = 0;
|
||||
private time = 0;
|
||||
private isDestroyed = false;
|
||||
|
||||
// Galaxy parameters
|
||||
private sphereRadius = 300;
|
||||
private sphereRotationX = 0;
|
||||
private sphereRotationY = 0;
|
||||
private sphereRotationZ = 0;
|
||||
|
||||
// Storm parameters
|
||||
private stormIntensity = 0;
|
||||
private stormAngle = 0;
|
||||
private blurFilter: BlurFilter;
|
||||
|
||||
// Reveal parameters
|
||||
private revealProgress = 0;
|
||||
private scrollScale = 0;
|
||||
|
||||
// Text style
|
||||
private nameStyle: TextStyle;
|
||||
private winnerStyle: TextStyle;
|
||||
|
||||
// Callbacks
|
||||
private onPhaseChange?: (phase: LotteryPhase) => void;
|
||||
private onWinnersRevealed?: (winners: Participant[]) => void;
|
||||
|
||||
constructor() {
|
||||
this.app = new Application();
|
||||
this.mainContainer = new Container();
|
||||
this.galaxyContainer = new Container();
|
||||
this.stormContainer = new Container();
|
||||
this.revealContainer = new Container();
|
||||
this.confettiContainer = new Container();
|
||||
this.scrollContainer = new Container();
|
||||
this.dimOverlay = new Graphics();
|
||||
this.blurFilter = new BlurFilter({ strength: 0 });
|
||||
|
||||
this.nameStyle = new TextStyle({
|
||||
fontFamily: '"Noto Serif SC", serif',
|
||||
fontSize: 18,
|
||||
fill: COLORS.gold,
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
|
||||
this.winnerStyle = new TextStyle({
|
||||
fontFamily: '"Noto Serif SC", serif',
|
||||
fontSize: 48,
|
||||
fill: COLORS.goldLight,
|
||||
fontWeight: 'bold',
|
||||
dropShadow: {
|
||||
color: COLORS.gold,
|
||||
blur: 10,
|
||||
distance: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 layer hierarchy
|
||||
this.mainContainer.addChild(this.galaxyContainer);
|
||||
this.mainContainer.addChild(this.stormContainer);
|
||||
this.mainContainer.addChild(this.dimOverlay);
|
||||
this.mainContainer.addChild(this.scrollContainer);
|
||||
this.mainContainer.addChild(this.revealContainer);
|
||||
this.mainContainer.addChild(this.confettiContainer);
|
||||
this.app.stage.addChild(this.mainContainer);
|
||||
|
||||
// Apply blur filter to storm container
|
||||
this.stormContainer.filters = [this.blurFilter];
|
||||
|
||||
// Start animation loop
|
||||
this.app.ticker.add(this.update.bind(this));
|
||||
|
||||
// Handle resize
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Set participants for the lottery
|
||||
*/
|
||||
setParticipants(participants: Participant[]): void {
|
||||
this.participants = participants;
|
||||
this.createNameParticles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter participants by criteria (e.g., zodiac for Round 4)
|
||||
*/
|
||||
filterParticipants(filter: { zodiac?: string; ageRange?: { min: number; max: number } }): void {
|
||||
this.nameParticles.forEach((p) => {
|
||||
const participant = this.participants.find((part) => part.id === p.id);
|
||||
if (!participant) return;
|
||||
|
||||
let eligible = true;
|
||||
|
||||
if (filter.zodiac && participant.zodiac !== filter.zodiac) {
|
||||
eligible = false;
|
||||
}
|
||||
|
||||
if (filter.ageRange) {
|
||||
const age = participant.age || 0;
|
||||
if (age < filter.ageRange.min || age > filter.ageRange.max) {
|
||||
eligible = false;
|
||||
}
|
||||
}
|
||||
|
||||
p.isEligible = eligible;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the lottery - transition to Galaxy phase
|
||||
*/
|
||||
startGalaxy(): void {
|
||||
this.phase = 'galaxy';
|
||||
this.phaseTime = 0;
|
||||
this.onPhaseChange?.('galaxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the storm - rolling phase
|
||||
*/
|
||||
startStorm(): void {
|
||||
this.phase = 'storm';
|
||||
this.phaseTime = 0;
|
||||
this.stormIntensity = 0;
|
||||
this.onPhaseChange?.('storm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and reveal winners
|
||||
*/
|
||||
revealWinners(winnerIds: string[]): void {
|
||||
this.winners = this.participants.filter((p) => winnerIds.includes(p.id));
|
||||
|
||||
// Mark winner particles
|
||||
this.nameParticles.forEach((p) => {
|
||||
p.isWinner = winnerIds.includes(p.id);
|
||||
});
|
||||
|
||||
this.phase = 'reveal';
|
||||
this.phaseTime = 0;
|
||||
this.revealProgress = 0;
|
||||
this.scrollScale = 0;
|
||||
this.onPhaseChange?.('reveal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to idle state
|
||||
*/
|
||||
reset(): void {
|
||||
this.phase = 'idle';
|
||||
this.phaseTime = 0;
|
||||
this.winners = [];
|
||||
this.confettiParticles = [];
|
||||
this.stormIntensity = 0;
|
||||
this.blurFilter.strength = 0;
|
||||
|
||||
// Reset particles
|
||||
this.nameParticles.forEach((p) => {
|
||||
p.isWinner = false;
|
||||
});
|
||||
|
||||
// Clear containers
|
||||
this.revealContainer.removeChildren();
|
||||
this.scrollContainer.removeChildren();
|
||||
this.confettiContainer.removeChildren();
|
||||
this.dimOverlay.clear();
|
||||
|
||||
this.onPhaseChange?.('idle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callbacks
|
||||
*/
|
||||
onPhase(callback: (phase: LotteryPhase) => void): void {
|
||||
this.onPhaseChange = callback;
|
||||
}
|
||||
|
||||
onWinners(callback: (winners: Participant[]) => void): void {
|
||||
this.onWinnersRevealed = callback;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Particle Creation
|
||||
// ============================================================================
|
||||
|
||||
private createNameParticles(): void {
|
||||
// Clear existing
|
||||
this.galaxyContainer.removeChildren();
|
||||
this.stormContainer.removeChildren();
|
||||
this.nameParticles = [];
|
||||
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const centerY = this.app.screen.height / 2;
|
||||
|
||||
this.participants.forEach((participant, index) => {
|
||||
// Distribute on sphere using fibonacci spiral
|
||||
const phi = Math.acos(1 - 2 * (index + 0.5) / this.participants.length);
|
||||
const theta = Math.PI * (1 + Math.sqrt(5)) * index;
|
||||
|
||||
const x = this.sphereRadius * Math.sin(phi) * Math.cos(theta);
|
||||
const y = this.sphereRadius * Math.sin(phi) * Math.sin(theta);
|
||||
const z = this.sphereRadius * Math.cos(phi);
|
||||
|
||||
// Create text
|
||||
const text = new Text({
|
||||
text: participant.name,
|
||||
style: this.nameStyle,
|
||||
});
|
||||
text.anchor.set(0.5);
|
||||
this.galaxyContainer.addChild(text);
|
||||
|
||||
const particle: NameParticle = {
|
||||
id: participant.id,
|
||||
name: participant.name,
|
||||
x: centerX + x,
|
||||
y: centerY + y,
|
||||
z,
|
||||
targetX: centerX,
|
||||
targetY: centerY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
rotation: 0,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.1,
|
||||
scale: 1,
|
||||
alpha: 1,
|
||||
isEligible: participant.isEligible !== false,
|
||||
isWinner: false,
|
||||
text,
|
||||
};
|
||||
|
||||
this.nameParticles.push(particle);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animation Loop
|
||||
// ============================================================================
|
||||
|
||||
private update(ticker: Ticker): void {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
this.time += ticker.deltaMS;
|
||||
this.phaseTime += ticker.deltaMS;
|
||||
|
||||
switch (this.phase) {
|
||||
case 'galaxy':
|
||||
this.updateGalaxy(ticker.deltaMS);
|
||||
break;
|
||||
case 'storm':
|
||||
this.updateStorm(ticker.deltaMS);
|
||||
break;
|
||||
case 'reveal':
|
||||
this.updateReveal(ticker.deltaMS);
|
||||
break;
|
||||
case 'complete':
|
||||
this.updateComplete(ticker.deltaMS);
|
||||
break;
|
||||
}
|
||||
|
||||
// Always update confetti
|
||||
this.updateConfetti(ticker.deltaMS);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Galaxy Phase (Idle Sphere)
|
||||
// ============================================================================
|
||||
|
||||
private updateGalaxy(deltaMs: number): void {
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const centerY = this.app.screen.height / 2;
|
||||
|
||||
// Slowly rotate the sphere
|
||||
this.sphereRotationY += deltaMs * 0.0003;
|
||||
this.sphereRotationX = Math.sin(this.time * 0.0001) * 0.2;
|
||||
|
||||
this.nameParticles.forEach((p) => {
|
||||
if (!p.text) return;
|
||||
|
||||
// Apply 3D rotation
|
||||
const cosY = Math.cos(this.sphereRotationY);
|
||||
const sinY = Math.sin(this.sphereRotationY);
|
||||
const cosX = Math.cos(this.sphereRotationX);
|
||||
const sinX = Math.sin(this.sphereRotationX);
|
||||
|
||||
// Rotate around Y axis
|
||||
const x1 = (p.x - centerX) * cosY - p.z * sinY;
|
||||
const z1 = (p.x - centerX) * sinY + p.z * cosY;
|
||||
|
||||
// Rotate around X axis
|
||||
const y1 = (p.y - centerY) * cosX - z1 * sinX;
|
||||
const z2 = (p.y - centerY) * sinX + z1 * cosX;
|
||||
|
||||
// Project to 2D with perspective
|
||||
const perspective = 800;
|
||||
const scale = perspective / (perspective + z2);
|
||||
|
||||
p.text.x = centerX + x1 * scale;
|
||||
p.text.y = centerY + y1 * scale;
|
||||
p.text.scale.set(scale * 0.8);
|
||||
|
||||
// Depth-based alpha and color
|
||||
const depthAlpha = (z2 + this.sphereRadius) / (this.sphereRadius * 2);
|
||||
p.text.alpha = p.isEligible ? 0.3 + depthAlpha * 0.7 : 0.15;
|
||||
|
||||
// Dim ineligible names
|
||||
if (!p.isEligible) {
|
||||
p.text.style.fill = 0x666666;
|
||||
} else {
|
||||
p.text.style.fill = COLORS.gold;
|
||||
}
|
||||
|
||||
// Z-sorting
|
||||
p.text.zIndex = Math.floor(z2);
|
||||
});
|
||||
|
||||
this.galaxyContainer.sortChildren();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storm Phase (Tornado)
|
||||
// ============================================================================
|
||||
|
||||
private updateStorm(deltaMs: number): void {
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const centerY = this.app.screen.height / 2;
|
||||
|
||||
// Ramp up storm intensity
|
||||
this.stormIntensity = Math.min(1, this.stormIntensity + deltaMs * 0.001);
|
||||
this.stormAngle += deltaMs * 0.01 * this.stormIntensity;
|
||||
|
||||
// Apply motion blur based on intensity
|
||||
this.blurFilter.strength = this.stormIntensity * 8;
|
||||
|
||||
this.nameParticles.forEach((p, index) => {
|
||||
if (!p.text) return;
|
||||
|
||||
// Tornado vortex motion
|
||||
const baseAngle = this.stormAngle + (index / this.nameParticles.length) * Math.PI * 2;
|
||||
const verticalPos = ((this.time * 0.001 + index * 0.1) % 2) - 1; // -1 to 1
|
||||
const radius = 100 + Math.abs(verticalPos) * 200 * this.stormIntensity;
|
||||
|
||||
const targetX = centerX + Math.cos(baseAngle) * radius;
|
||||
const targetY = centerY + verticalPos * 300;
|
||||
|
||||
// Smooth interpolation
|
||||
p.x += (targetX - p.x) * 0.1;
|
||||
p.y += (targetY - p.y) * 0.1;
|
||||
|
||||
p.text.x = p.x;
|
||||
p.text.y = p.y;
|
||||
p.text.rotation += p.rotationSpeed * this.stormIntensity * 3;
|
||||
p.text.alpha = p.isEligible ? 0.8 : 0.2;
|
||||
|
||||
// Scale based on position
|
||||
const distFromCenter = Math.sqrt(
|
||||
Math.pow(p.x - centerX, 2) + Math.pow(p.y - centerY, 2)
|
||||
);
|
||||
p.text.scale.set(0.5 + (1 - distFromCenter / 400) * 0.5);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reveal Phase
|
||||
// ============================================================================
|
||||
|
||||
private updateReveal(deltaMs: number): void {
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const centerY = this.app.screen.height / 2;
|
||||
|
||||
this.revealProgress = Math.min(1, this.revealProgress + deltaMs * 0.0008);
|
||||
const eased = Easing.easeOutQuart(this.revealProgress);
|
||||
|
||||
// Reduce blur
|
||||
this.blurFilter.strength = (1 - eased) * 8;
|
||||
|
||||
// Dim background
|
||||
this.dimOverlay.clear();
|
||||
this.dimOverlay.rect(0, 0, this.app.screen.width, this.app.screen.height);
|
||||
this.dimOverlay.fill({ color: COLORS.black, alpha: eased * 0.7 });
|
||||
|
||||
// Disperse non-winners outward
|
||||
this.nameParticles.forEach((p) => {
|
||||
if (!p.text) return;
|
||||
|
||||
if (p.isWinner) {
|
||||
// Winners fly to center
|
||||
const targetX = centerX;
|
||||
const targetY = centerY - 50;
|
||||
|
||||
p.x += (targetX - p.x) * 0.05 * eased;
|
||||
p.y += (targetY - p.y) * 0.05 * eased;
|
||||
p.text.alpha = 1;
|
||||
p.text.scale.set(1 + eased * 1.5);
|
||||
} else {
|
||||
// Non-winners disperse
|
||||
const angle = Math.atan2(p.y - centerY, p.x - centerX);
|
||||
const disperseDistance = 500 * eased;
|
||||
|
||||
p.x += Math.cos(angle) * disperseDistance * 0.02;
|
||||
p.y += Math.sin(angle) * disperseDistance * 0.02;
|
||||
p.text.alpha = Math.max(0, 1 - eased * 1.5);
|
||||
}
|
||||
|
||||
p.text.x = p.x;
|
||||
p.text.y = p.y;
|
||||
});
|
||||
|
||||
// Draw scroll background
|
||||
if (this.revealProgress > 0.3) {
|
||||
this.scrollScale = Easing.easeOutBack((this.revealProgress - 0.3) / 0.7);
|
||||
this.drawScroll(centerX, centerY, this.scrollScale);
|
||||
}
|
||||
|
||||
// Spawn confetti when reveal is complete
|
||||
if (this.revealProgress >= 1 && this.phase === 'reveal') {
|
||||
this.phase = 'complete';
|
||||
this.spawnConfettiBurst();
|
||||
this.createWinnerDisplay();
|
||||
this.onWinnersRevealed?.(this.winners);
|
||||
this.onPhaseChange?.('complete');
|
||||
}
|
||||
}
|
||||
|
||||
private drawScroll(centerX: number, centerY: number, scale: number): void {
|
||||
this.scrollContainer.removeChildren();
|
||||
|
||||
const scrollGraphics = new Graphics();
|
||||
const width = 600 * scale;
|
||||
const height = 400 * scale;
|
||||
|
||||
// Main scroll body
|
||||
scrollGraphics.roundRect(
|
||||
centerX - width / 2,
|
||||
centerY - height / 2,
|
||||
width,
|
||||
height,
|
||||
20 * scale
|
||||
);
|
||||
scrollGraphics.fill({ color: COLORS.redDark, alpha: 0.95 });
|
||||
|
||||
// Gold border
|
||||
scrollGraphics.roundRect(
|
||||
centerX - width / 2 + 5,
|
||||
centerY - height / 2 + 5,
|
||||
width - 10,
|
||||
height - 10,
|
||||
15 * scale
|
||||
);
|
||||
scrollGraphics.stroke({ color: COLORS.gold, width: 3 * scale });
|
||||
|
||||
// Inner decorative border
|
||||
scrollGraphics.roundRect(
|
||||
centerX - width / 2 + 15,
|
||||
centerY - height / 2 + 15,
|
||||
width - 30,
|
||||
height - 30,
|
||||
10 * scale
|
||||
);
|
||||
scrollGraphics.stroke({ color: COLORS.goldDark, width: 1 * scale });
|
||||
|
||||
this.scrollContainer.addChild(scrollGraphics);
|
||||
}
|
||||
|
||||
private createWinnerDisplay(): void {
|
||||
this.revealContainer.removeChildren();
|
||||
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const centerY = this.app.screen.height / 2;
|
||||
|
||||
// Arrange winners
|
||||
const winnerCount = this.winners.length;
|
||||
const spacing = Math.min(120, 500 / winnerCount);
|
||||
|
||||
this.winners.forEach((winner, index) => {
|
||||
const offsetX = (index - (winnerCount - 1) / 2) * spacing;
|
||||
|
||||
const text = new Text({
|
||||
text: winner.name,
|
||||
style: this.winnerStyle,
|
||||
});
|
||||
text.anchor.set(0.5);
|
||||
text.x = centerX + offsetX;
|
||||
text.y = centerY - 30;
|
||||
|
||||
// Department subtitle
|
||||
const deptText = new Text({
|
||||
text: winner.department,
|
||||
style: new TextStyle({
|
||||
fontFamily: '"Noto Serif SC", serif',
|
||||
fontSize: 20,
|
||||
fill: COLORS.goldDark,
|
||||
}),
|
||||
});
|
||||
deptText.anchor.set(0.5);
|
||||
deptText.x = centerX + offsetX;
|
||||
deptText.y = centerY + 30;
|
||||
|
||||
this.revealContainer.addChild(text);
|
||||
this.revealContainer.addChild(deptText);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Complete Phase
|
||||
// ============================================================================
|
||||
|
||||
private updateComplete(deltaMs: number): void {
|
||||
// Gentle pulsing of winner names
|
||||
const pulse = Math.sin(this.time * 0.003) * 0.05 + 1;
|
||||
|
||||
this.revealContainer.children.forEach((child, index) => {
|
||||
if (child instanceof Text && index % 2 === 0) {
|
||||
child.scale.set(pulse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Confetti System
|
||||
// ============================================================================
|
||||
|
||||
private spawnConfettiBurst(): void {
|
||||
const centerX = this.app.screen.width / 2;
|
||||
const colors = [COLORS.gold, COLORS.goldLight, COLORS.red, COLORS.redLight];
|
||||
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 5 + Math.random() * 10;
|
||||
|
||||
this.confettiParticles.push({
|
||||
x: centerX + (Math.random() - 0.5) * 200,
|
||||
y: this.app.screen.height * 0.3,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed - 5,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.3,
|
||||
width: 8 + Math.random() * 8,
|
||||
height: 4 + Math.random() * 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
alpha: 1,
|
||||
gravity: 0.15 + Math.random() * 0.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateConfetti(deltaMs: number): void {
|
||||
const confettiGraphics = new Graphics();
|
||||
this.confettiContainer.removeChildren();
|
||||
|
||||
this.confettiParticles = this.confettiParticles.filter((p) => {
|
||||
// Physics
|
||||
p.vy += p.gravity;
|
||||
p.vx *= 0.99; // Air resistance
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.rotation += p.rotationSpeed;
|
||||
|
||||
// Fade out when below screen
|
||||
if (p.y > this.app.screen.height) {
|
||||
p.alpha -= 0.02;
|
||||
}
|
||||
|
||||
if (p.alpha <= 0) return false;
|
||||
|
||||
// Draw confetti piece (calculate rotated rectangle corners)
|
||||
const cos = Math.cos(p.rotation);
|
||||
const sin = Math.sin(p.rotation);
|
||||
const hw = p.width / 2;
|
||||
const hh = p.height / 2;
|
||||
|
||||
// Calculate rotated corners
|
||||
const corners = [
|
||||
{ x: -hw, y: -hh },
|
||||
{ x: hw, y: -hh },
|
||||
{ x: hw, y: hh },
|
||||
{ x: -hw, y: hh },
|
||||
].map(corner => ({
|
||||
x: p.x + corner.x * cos - corner.y * sin,
|
||||
y: p.y + corner.x * sin + corner.y * cos,
|
||||
}));
|
||||
|
||||
// Draw as polygon
|
||||
confettiGraphics.moveTo(corners[0].x, corners[0].y);
|
||||
confettiGraphics.lineTo(corners[1].x, corners[1].y);
|
||||
confettiGraphics.lineTo(corners[2].x, corners[2].y);
|
||||
confettiGraphics.lineTo(corners[3].x, corners[3].y);
|
||||
confettiGraphics.closePath();
|
||||
confettiGraphics.fill({ color: p.color, alpha: p.alpha });
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.confettiContainer.addChild(confettiGraphics);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
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.nameParticles = [];
|
||||
this.confettiParticles = [];
|
||||
this.app.destroy(true, { children: true });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Getters
|
||||
// ============================================================================
|
||||
|
||||
get currentPhase(): LotteryPhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
get currentWinners(): Participant[] {
|
||||
return this.winners;
|
||||
}
|
||||
}
|
||||
492
packages/client-screen/src/pixi/VoteParticleSystem.ts
Normal file
492
packages/client-screen/src/pixi/VoteParticleSystem.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { Application, Container, Graphics, Ticker } from 'pixi.js';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const COLORS = {
|
||||
gold: 0xf0c239,
|
||||
goldLight: 0xffd700,
|
||||
goldDark: 0xd4a84b,
|
||||
red: 0xc21f30,
|
||||
redLight: 0xe63946,
|
||||
};
|
||||
|
||||
// Easing functions for smooth animation
|
||||
const Easing = {
|
||||
// Smooth acceleration then deceleration
|
||||
easeInOutCubic: (t: number): number => {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
},
|
||||
// Fast start, slow end
|
||||
easeOutQuart: (t: number): number => {
|
||||
return 1 - Math.pow(1 - t, 4);
|
||||
},
|
||||
// Slow start, fast end
|
||||
easeInQuad: (t: number): number => {
|
||||
return t * t;
|
||||
},
|
||||
// Bounce at end
|
||||
easeOutBack: (t: number): number => {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface VoteParticle {
|
||||
x: number;
|
||||
y: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
controlX: number;
|
||||
controlY: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
glowSize: number;
|
||||
trailPoints: Array<{ x: number; y: number; alpha: number }>;
|
||||
phase: 'flying' | 'impact' | 'done';
|
||||
impactTime: number;
|
||||
}
|
||||
|
||||
interface ImpactEffect {
|
||||
x: number;
|
||||
y: number;
|
||||
particles: Array<{
|
||||
angle: number;
|
||||
speed: number;
|
||||
distance: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
}>;
|
||||
progress: number;
|
||||
phase: 'burst' | 'fade';
|
||||
}
|
||||
|
||||
interface TargetGlow {
|
||||
x: number;
|
||||
y: number;
|
||||
intensity: number;
|
||||
pulsePhase: number;
|
||||
baseIntensity: number;
|
||||
}
|
||||
|
||||
export interface ProgramTarget {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VoteParticleSystem Class
|
||||
// ============================================================================
|
||||
|
||||
export class VoteParticleSystem {
|
||||
private app: Application;
|
||||
private container: Container;
|
||||
private particleLayer: Graphics;
|
||||
private glowLayer: Graphics;
|
||||
private impactLayer: Graphics;
|
||||
|
||||
private particles: VoteParticle[] = [];
|
||||
private impacts: ImpactEffect[] = [];
|
||||
private targetGlows: Map<string, TargetGlow> = new Map();
|
||||
private programTargets: Map<string, ProgramTarget> = new Map();
|
||||
|
||||
private isDestroyed = false;
|
||||
private time = 0;
|
||||
|
||||
// Configuration
|
||||
private readonly MAX_PARTICLES = 200;
|
||||
private readonly TRAIL_LENGTH = 12;
|
||||
private readonly PARTICLE_DURATION = 1500; // ms
|
||||
private readonly IMPACT_DURATION = 600; // ms
|
||||
|
||||
constructor() {
|
||||
this.app = new Application();
|
||||
this.container = new Container();
|
||||
this.particleLayer = new Graphics();
|
||||
this.glowLayer = new Graphics();
|
||||
this.impactLayer = new Graphics();
|
||||
}
|
||||
|
||||
async init(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
// Layer order: glow (back) -> particles -> impacts (front)
|
||||
this.container.addChild(this.glowLayer);
|
||||
this.container.addChild(this.particleLayer);
|
||||
this.container.addChild(this.impactLayer);
|
||||
this.app.stage.addChild(this.container);
|
||||
|
||||
// Start animation loop
|
||||
this.app.ticker.add(this.update.bind(this));
|
||||
|
||||
// Handle resize
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Register a program target position for vote particles to fly towards
|
||||
*/
|
||||
registerTarget(target: ProgramTarget): void {
|
||||
this.programTargets.set(target.id, target);
|
||||
|
||||
// Initialize glow state
|
||||
if (!this.targetGlows.has(target.id)) {
|
||||
this.targetGlows.set(target.id, {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
intensity: 0,
|
||||
pulsePhase: Math.random() * Math.PI * 2,
|
||||
baseIntensity: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update target position (for responsive layouts)
|
||||
*/
|
||||
updateTargetPosition(id: string, x: number, y: number): void {
|
||||
const target = this.programTargets.get(id);
|
||||
if (target) {
|
||||
target.x = x;
|
||||
target.y = y;
|
||||
}
|
||||
|
||||
const glow = this.targetGlows.get(id);
|
||||
if (glow) {
|
||||
glow.x = x;
|
||||
glow.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a vote particle flying towards a program target
|
||||
*/
|
||||
spawnVoteParticle(targetId: string): void {
|
||||
if (this.isDestroyed) return;
|
||||
if (this.particles.length >= this.MAX_PARTICLES) return;
|
||||
|
||||
const target = this.programTargets.get(targetId);
|
||||
if (!target) {
|
||||
console.warn(`[VoteParticle] Unknown target: ${targetId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn from bottom edge with random horizontal position
|
||||
const screenWidth = this.app.screen.width;
|
||||
const screenHeight = this.app.screen.height;
|
||||
|
||||
const startX = Math.random() * screenWidth;
|
||||
const startY = screenHeight + 20;
|
||||
|
||||
// Calculate bezier control point for curved trajectory
|
||||
// Control point is offset to create an arc
|
||||
const midX = (startX + target.x) / 2;
|
||||
const midY = (startY + target.y) / 2;
|
||||
const perpX = -(target.y - startY);
|
||||
const perpY = target.x - startX;
|
||||
const perpLen = Math.sqrt(perpX * perpX + perpY * perpY);
|
||||
const curveAmount = (Math.random() - 0.5) * 300; // Random curve direction
|
||||
|
||||
const controlX = midX + (perpX / perpLen) * curveAmount;
|
||||
const controlY = midY + (perpY / perpLen) * curveAmount;
|
||||
|
||||
const particle: VoteParticle = {
|
||||
x: startX,
|
||||
y: startY,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
startX,
|
||||
startY,
|
||||
controlX,
|
||||
controlY,
|
||||
progress: 0,
|
||||
speed: 0.8 + Math.random() * 0.4, // Vary speed slightly
|
||||
size: 4 + Math.random() * 3,
|
||||
alpha: 1,
|
||||
glowSize: 15 + Math.random() * 10,
|
||||
trailPoints: [],
|
||||
phase: 'flying',
|
||||
impactTime: 0,
|
||||
};
|
||||
|
||||
this.particles.push(particle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn multiple particles at once (for burst effect)
|
||||
*/
|
||||
spawnVoteParticleBurst(targetId: string, count: number = 3): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
setTimeout(() => {
|
||||
this.spawnVoteParticle(targetId);
|
||||
}, i * 50); // Stagger spawns
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase heat/glow intensity for a target
|
||||
*/
|
||||
addHeat(targetId: string, amount: number = 0.3): void {
|
||||
const glow = this.targetGlows.get(targetId);
|
||||
if (glow) {
|
||||
glow.baseIntensity = Math.min(1, glow.baseIntensity + amount);
|
||||
glow.intensity = Math.min(1.5, glow.intensity + amount * 2); // Pulse effect
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animation Loop
|
||||
// ============================================================================
|
||||
|
||||
private update(ticker: Ticker): void {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
this.time += ticker.deltaMS;
|
||||
|
||||
// Clear all layers
|
||||
this.particleLayer.clear();
|
||||
this.glowLayer.clear();
|
||||
this.impactLayer.clear();
|
||||
|
||||
// Update and draw target glows
|
||||
this.updateTargetGlows(ticker.deltaMS);
|
||||
|
||||
// Update and draw particles
|
||||
this.updateParticles(ticker.deltaMS);
|
||||
|
||||
// Update and draw impact effects
|
||||
this.updateImpacts(ticker.deltaMS);
|
||||
}
|
||||
|
||||
private updateTargetGlows(deltaMs: number): void {
|
||||
this.targetGlows.forEach((glow, id) => {
|
||||
// Decay intensity over time
|
||||
glow.intensity = Math.max(glow.baseIntensity, glow.intensity - deltaMs * 0.002);
|
||||
glow.baseIntensity = Math.max(0, glow.baseIntensity - deltaMs * 0.0002);
|
||||
|
||||
// Pulse animation
|
||||
glow.pulsePhase += deltaMs * 0.003;
|
||||
const pulse = Math.sin(glow.pulsePhase) * 0.1 + 1;
|
||||
|
||||
if (glow.intensity > 0.01) {
|
||||
this.drawTargetGlow(glow, pulse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private drawTargetGlow(glow: TargetGlow, pulse: number): void {
|
||||
const intensity = glow.intensity * pulse;
|
||||
const maxRadius = 60 + intensity * 40;
|
||||
|
||||
// Draw multiple concentric circles for glow effect
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const ratio = i / 5;
|
||||
const radius = maxRadius * (0.3 + ratio * 0.7);
|
||||
const alpha = intensity * (1 - ratio) * 0.4;
|
||||
|
||||
this.glowLayer.circle(glow.x, glow.y, radius);
|
||||
this.glowLayer.fill({ color: COLORS.gold, alpha });
|
||||
}
|
||||
|
||||
// Inner bright core
|
||||
this.glowLayer.circle(glow.x, glow.y, 8 + intensity * 5);
|
||||
this.glowLayer.fill({ color: COLORS.goldLight, alpha: intensity * 0.8 });
|
||||
}
|
||||
|
||||
private updateParticles(deltaMs: number): void {
|
||||
this.particles = this.particles.filter((p) => {
|
||||
if (p.phase === 'flying') {
|
||||
// Update progress along bezier curve
|
||||
p.progress += (deltaMs / this.PARTICLE_DURATION) * p.speed;
|
||||
|
||||
if (p.progress >= 1) {
|
||||
// Reached target - trigger impact
|
||||
p.phase = 'impact';
|
||||
p.impactTime = 0;
|
||||
this.triggerImpact(p.targetX, p.targetY);
|
||||
|
||||
// Find target and add heat
|
||||
for (const [id, target] of this.programTargets) {
|
||||
if (target.x === p.targetX && target.y === p.targetY) {
|
||||
this.addHeat(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate position on quadratic bezier curve with easing
|
||||
const t = Easing.easeInOutCubic(p.progress);
|
||||
const invT = 1 - t;
|
||||
|
||||
p.x = invT * invT * p.startX + 2 * invT * t * p.controlX + t * t * p.targetX;
|
||||
p.y = invT * invT * p.startY + 2 * invT * t * p.controlY + t * t * p.targetY;
|
||||
|
||||
// Update trail
|
||||
p.trailPoints.unshift({ x: p.x, y: p.y, alpha: 1 });
|
||||
if (p.trailPoints.length > this.TRAIL_LENGTH) {
|
||||
p.trailPoints.pop();
|
||||
}
|
||||
|
||||
// Fade trail points
|
||||
p.trailPoints.forEach((point, i) => {
|
||||
point.alpha = 1 - i / this.TRAIL_LENGTH;
|
||||
});
|
||||
|
||||
// Draw particle
|
||||
this.drawParticle(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private drawParticle(p: VoteParticle): void {
|
||||
// Draw trail
|
||||
p.trailPoints.forEach((point, i) => {
|
||||
const size = p.size * (1 - i / this.TRAIL_LENGTH * 0.7);
|
||||
const alpha = point.alpha * 0.6;
|
||||
|
||||
this.particleLayer.circle(point.x, point.y, size);
|
||||
this.particleLayer.fill({ color: COLORS.gold, alpha });
|
||||
});
|
||||
|
||||
// Draw glow around particle
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const ratio = i / 3;
|
||||
const radius = p.glowSize * (0.3 + ratio * 0.7);
|
||||
const alpha = (1 - ratio) * 0.3 * p.alpha;
|
||||
|
||||
this.particleLayer.circle(p.x, p.y, radius);
|
||||
this.particleLayer.fill({ color: COLORS.gold, alpha });
|
||||
}
|
||||
|
||||
// Draw bright core
|
||||
this.particleLayer.circle(p.x, p.y, p.size);
|
||||
this.particleLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
|
||||
|
||||
// Inner white hot center
|
||||
this.particleLayer.circle(p.x, p.y, p.size * 0.5);
|
||||
this.particleLayer.fill({ color: 0xffffff, alpha: p.alpha * 0.8 });
|
||||
}
|
||||
|
||||
private triggerImpact(x: number, y: number): void {
|
||||
const particleCount = 12 + Math.floor(Math.random() * 8);
|
||||
const impactParticles: ImpactEffect['particles'] = [];
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / particleCount + Math.random() * 0.3;
|
||||
impactParticles.push({
|
||||
angle,
|
||||
speed: 2 + Math.random() * 3,
|
||||
distance: 0,
|
||||
size: 2 + Math.random() * 3,
|
||||
alpha: 1,
|
||||
});
|
||||
}
|
||||
|
||||
this.impacts.push({
|
||||
x,
|
||||
y,
|
||||
particles: impactParticles,
|
||||
progress: 0,
|
||||
phase: 'burst',
|
||||
});
|
||||
}
|
||||
|
||||
private updateImpacts(deltaMs: number): void {
|
||||
this.impacts = this.impacts.filter((impact) => {
|
||||
impact.progress += deltaMs / this.IMPACT_DURATION;
|
||||
|
||||
if (impact.progress >= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update impact particles
|
||||
const t = Easing.easeOutQuart(impact.progress);
|
||||
|
||||
impact.particles.forEach((p) => {
|
||||
p.distance = t * 50 * p.speed;
|
||||
p.alpha = 1 - Easing.easeInQuad(impact.progress);
|
||||
p.size = p.size * (1 + t * 0.5);
|
||||
});
|
||||
|
||||
// Draw impact
|
||||
this.drawImpact(impact);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private drawImpact(impact: ImpactEffect): void {
|
||||
// Draw central flash
|
||||
const flashAlpha = (1 - impact.progress) * 0.8;
|
||||
const flashSize = 20 + impact.progress * 30;
|
||||
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const ratio = i / 3;
|
||||
const radius = flashSize * (0.3 + ratio * 0.7);
|
||||
const alpha = flashAlpha * (1 - ratio);
|
||||
|
||||
this.impactLayer.circle(impact.x, impact.y, radius);
|
||||
this.impactLayer.fill({ color: COLORS.goldLight, alpha });
|
||||
}
|
||||
|
||||
// Draw burst particles
|
||||
impact.particles.forEach((p) => {
|
||||
const px = impact.x + Math.cos(p.angle) * p.distance;
|
||||
const py = impact.y + Math.sin(p.angle) * p.distance;
|
||||
|
||||
// Particle glow
|
||||
this.impactLayer.circle(px, py, p.size * 2);
|
||||
this.impactLayer.fill({ color: COLORS.gold, alpha: p.alpha * 0.3 });
|
||||
|
||||
// Particle core
|
||||
this.impactLayer.circle(px, py, p.size);
|
||||
this.impactLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
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.particles = [];
|
||||
this.impacts = [];
|
||||
this.targetGlows.clear();
|
||||
this.programTargets.clear();
|
||||
this.app.destroy(true, { children: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user