- 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>
383 lines
13 KiB
TypeScript
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 });
|
|
}
|
|
}
|