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