Files
company-celebration/packages/client-screen/src/pixi/LotteryMachine.ts
empty a442d050e4 feat: enhance lottery system with participant import and prize config
- 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>
2026-01-23 12:20:45 +08:00

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