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:
empty
2026-01-15 15:34:37 +08:00
parent e7397d22a9
commit 30cd29d45d
45 changed files with 7791 additions and 715 deletions

View 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 });
}
}

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

View 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 });
}
}