Files
company-celebration/packages/client-screen/src/pixi/HorseRaceRenderer.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

383 lines
13 KiB
TypeScript

import { Application, Container, Graphics, Text, TextStyle, Ticker } from 'pixi.js';
// ============================================================================
// Constants
// ============================================================================
const COLORS = {
gold: 0xf0c239,
goldLight: 0xffd700,
goldDark: 0xd4a84b,
red: 0xc21f30,
trackLine: 0x333333,
trackBg: 0x1a1a1a,
textMuted: 0x888888,
};
const Easing = {
easeOutQuart: (t: number): number => 1 - Math.pow(1 - t, 4),
easeInOutCubic: (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
};
// ============================================================================
// Types
// ============================================================================
export interface RaceHorse {
id: string;
name: string;
votes: number;
trackIndex: number;
// Animation state
currentX: number;
targetX: number;
glowIntensity: number;
targetGlowIntensity: number;
particles: Array<{ x: number; y: number; alpha: number; speed: number }>;
}
export interface HorseRaceConfig {
trackCount: number;
trackHeight: number;
trackPadding: number;
minX: number;
maxX: number;
horseSize: number;
}
// ============================================================================
// HorseRaceRenderer Class
// ============================================================================
export class HorseRaceRenderer {
private app: Application;
private container: Container;
private trackLayer: Graphics;
private horseLayer: Graphics;
private labelLayer: Container;
private horses: Map<string, RaceHorse> = new Map();
private maxVotes: number = 1;
private config: HorseRaceConfig;
private isDestroyed = false;
private time = 0;
constructor() {
this.app = new Application();
this.container = new Container();
this.trackLayer = new Graphics();
this.horseLayer = new Graphics();
this.labelLayer = new Container();
this.config = {
trackCount: 8,
trackHeight: 80,
trackPadding: 20,
minX: 150,
maxX: 0, // Will be set on init
horseSize: 20,
};
}
async init(canvas: HTMLCanvasElement): Promise<void> {
await this.app.init({
canvas,
width: window.innerWidth,
height: window.innerHeight,
background: COLORS.trackBg,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
this.config.maxX = this.app.screen.width - 100;
this.container.addChild(this.trackLayer);
this.container.addChild(this.horseLayer);
this.container.addChild(this.labelLayer);
this.app.stage.addChild(this.container);
this.app.ticker.add(this.update.bind(this));
window.addEventListener('resize', this.handleResize.bind(this));
this.drawTracks();
}
// ============================================================================
// Public API
// ============================================================================
/**
* Set the list of programs (horses) to display
*/
setPrograms(programs: Array<{ id: string; name: string; votes: number }>): void {
// Update max votes
this.maxVotes = Math.max(1, ...programs.map((p) => p.votes));
programs.forEach((program, index) => {
const trackIndex = index % this.config.trackCount;
const trackY = this.getTrackY(trackIndex);
if (this.horses.has(program.id)) {
// Update existing horse
const horse = this.horses.get(program.id)!;
horse.votes = program.votes;
horse.targetX = this.calculateHorseX(program.votes);
horse.targetGlowIntensity = this.calculateGlowIntensity(program.votes);
} else {
// Create new horse
const startX = this.config.minX;
const horse: RaceHorse = {
id: program.id,
name: program.name,
votes: program.votes,
trackIndex,
currentX: startX,
targetX: this.calculateHorseX(program.votes),
glowIntensity: 0,
targetGlowIntensity: this.calculateGlowIntensity(program.votes),
particles: [],
};
this.horses.set(program.id, horse);
// Create label
this.createHorseLabel(horse, trackY);
}
});
this.drawTracks();
}
/**
* Update a single program's votes
*/
updateVotes(programId: string, newVotes: number): void {
const horse = this.horses.get(programId);
if (!horse) return;
horse.votes = newVotes;
// Recalculate max votes
this.maxVotes = Math.max(1, ...Array.from(this.horses.values()).map((h) => h.votes));
// Update all horses' target positions
this.horses.forEach((h) => {
h.targetX = this.calculateHorseX(h.votes);
h.targetGlowIntensity = this.calculateGlowIntensity(h.votes);
});
// Add burst particles for the updated horse
this.addParticleBurst(horse);
}
// ============================================================================
// Calculation Helpers
// ============================================================================
private getTrackY(trackIndex: number): number {
const totalHeight = this.config.trackCount * this.config.trackHeight;
const startY = (this.app.screen.height - totalHeight) / 2;
return startY + trackIndex * this.config.trackHeight + this.config.trackHeight / 2;
}
private calculateHorseX(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return this.config.minX + (this.config.maxX - this.config.minX) * progress;
}
private calculateGlowIntensity(votes: number): number {
const progress = this.maxVotes > 0 ? votes / this.maxVotes : 0;
return 0.3 + progress * 0.7;
}
// ============================================================================
// Drawing
// ============================================================================
private drawTracks(): void {
this.trackLayer.clear();
for (let i = 0; i < this.config.trackCount; i++) {
const y = this.getTrackY(i);
// Track background stripe (alternating)
const stripeAlpha = i % 2 === 0 ? 0.05 : 0.02;
this.trackLayer.rect(
0,
y - this.config.trackHeight / 2,
this.app.screen.width,
this.config.trackHeight
);
this.trackLayer.fill({ color: 0xffffff, alpha: stripeAlpha });
// Track line
this.trackLayer.moveTo(this.config.minX - 20, y);
this.trackLayer.lineTo(this.config.maxX + 50, y);
this.trackLayer.stroke({ color: COLORS.trackLine, alpha: 0.3, width: 1 });
// Start line
this.trackLayer.moveTo(this.config.minX, y - 20);
this.trackLayer.lineTo(this.config.minX, y + 20);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.5, width: 2 });
}
// Finish line
const finishX = this.config.maxX;
this.trackLayer.moveTo(finishX, this.getTrackY(0) - this.config.trackHeight / 2);
this.trackLayer.lineTo(finishX, this.getTrackY(this.config.trackCount - 1) + this.config.trackHeight / 2);
this.trackLayer.stroke({ color: COLORS.gold, alpha: 0.8, width: 3 });
// Finish checkered pattern
const checkerSize = 10;
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 3; col++) {
if ((row + col) % 2 === 0) {
this.trackLayer.rect(
finishX + col * checkerSize,
this.getTrackY(0) - this.config.trackHeight / 2 + row * checkerSize,
checkerSize,
checkerSize
);
this.trackLayer.fill({ color: COLORS.gold, alpha: 0.6 });
}
}
}
}
private createHorseLabel(horse: RaceHorse, y: number): void {
const style = new TextStyle({
fontFamily: 'SimSun, Songti SC, serif',
fontSize: 16,
fill: COLORS.textMuted,
align: 'right',
});
const label = new Text({ text: horse.name, style });
label.anchor.set(1, 0.5);
label.x = this.config.minX - 30;
label.y = y;
label.name = `label-${horse.id}`;
this.labelLayer.addChild(label);
}
private drawHorse(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
const x = horse.currentX;
const size = this.config.horseSize;
const intensity = horse.glowIntensity;
// Outer glow rings
for (let i = 4; i >= 0; i--) {
const ratio = i / 4;
const glowRadius = size * (1.5 + ratio * 2) * intensity;
const alpha = (1 - ratio) * 0.2 * intensity;
this.horseLayer.circle(x, y, glowRadius);
this.horseLayer.fill({ color: COLORS.gold, alpha });
}
// Main body (gold orb)
this.horseLayer.circle(x, y, size);
this.horseLayer.fill({ color: COLORS.gold, alpha: 0.9 });
// Inner bright core
this.horseLayer.circle(x, y, size * 0.6);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: 1 });
// White hot center
this.horseLayer.circle(x, y, size * 0.3);
this.horseLayer.fill({ color: 0xffffff, alpha: 0.8 });
// Draw particles
horse.particles.forEach((p) => {
this.horseLayer.circle(p.x, y + (Math.random() - 0.5) * 10, 3);
this.horseLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
});
}
private addParticleBurst(horse: RaceHorse): void {
const y = this.getTrackY(horse.trackIndex);
for (let i = 0; i < 8; i++) {
horse.particles.push({
x: horse.currentX,
y,
alpha: 1,
speed: 2 + Math.random() * 3,
});
}
}
// ============================================================================
// Animation Loop
// ============================================================================
private update(ticker: Ticker): void {
if (this.isDestroyed) return;
this.time += ticker.deltaMS;
this.horseLayer.clear();
this.horses.forEach((horse) => {
// Smoothly interpolate position
const dx = horse.targetX - horse.currentX;
horse.currentX += dx * 0.08; // Smooth easing
// Smoothly interpolate glow
const dg = horse.targetGlowIntensity - horse.glowIntensity;
horse.glowIntensity += dg * 0.1;
// Update particles (trail effect)
horse.particles = horse.particles.filter((p) => {
p.x -= p.speed;
p.alpha -= 0.02;
return p.alpha > 0;
});
// Add trail particles when moving fast
if (Math.abs(dx) > 5 && Math.random() > 0.7) {
const y = this.getTrackY(horse.trackIndex);
horse.particles.push({
x: horse.currentX - this.config.horseSize,
y,
alpha: 0.6,
speed: 1 + Math.random() * 2,
});
}
this.drawHorse(horse);
});
}
// ============================================================================
// Lifecycle
// ============================================================================
private handleResize(): void {
if (this.isDestroyed) return;
this.app.renderer.resize(window.innerWidth, window.innerHeight);
this.config.maxX = this.app.screen.width - 100;
// Update label positions
this.horses.forEach((horse) => {
const label = this.labelLayer.getChildByName(`label-${horse.id}`) as Text;
if (label) {
label.y = this.getTrackY(horse.trackIndex);
}
horse.targetX = this.calculateHorseX(horse.votes);
});
this.drawTracks();
}
destroy(): void {
this.isDestroyed = true;
window.removeEventListener('resize', this.handleResize.bind(this));
this.horses.clear();
this.app.destroy(true, { children: true });
}
}