- Fix ES module import issue in admin.service.ts (require -> import) - Fix lottery reveal ghosting by hiding name particles on complete - Add participant import from Excel with tag calculation - Add prize configuration service with JSON persistence - Constrain winners overlay to scroll area dimensions - Fix macOS lsof syntax in stop script - Add HorseRace view and renderer (WIP) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
776 lines
22 KiB
TypeScript
776 lines
22 KiB
TypeScript
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 {
|
|
// Clear previous round's reveal display
|
|
this.winners = [];
|
|
this.confettiParticles = [];
|
|
this.revealContainer.removeChildren();
|
|
this.scrollContainer.removeChildren();
|
|
this.confettiContainer.removeChildren();
|
|
this.dimOverlay.clear();
|
|
|
|
// Reset particles
|
|
this.nameParticles.forEach((p) => {
|
|
p.isWinner = false;
|
|
});
|
|
|
|
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';
|
|
|
|
// Hide all name particles to prevent ghosting
|
|
this.nameParticles.forEach((p) => {
|
|
if (p.text) {
|
|
p.text.alpha = 0;
|
|
p.text.visible = false;
|
|
}
|
|
});
|
|
|
|
this.spawnConfettiBurst();
|
|
// Don't create Pixi winner display - Vue overlay handles it
|
|
// 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;
|
|
}
|
|
}
|