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>
This commit is contained in:
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal file
382
packages/client-screen/src/pixi/HorseRaceRenderer.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,19 @@ export class LotteryMachine {
|
||||
* 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');
|
||||
@@ -542,8 +555,18 @@ export class LotteryMachine {
|
||||
// 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();
|
||||
this.createWinnerDisplay();
|
||||
// Don't create Pixi winner display - Vue overlay handles it
|
||||
// this.createWinnerDisplay();
|
||||
this.onWinnersRevealed?.(this.winners);
|
||||
this.onPhaseChange?.('complete');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user