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 { 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; } }