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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user