Admin Control Panel: - Add full AdminControl.vue with 3 sections (Voting, Lottery, Global) - Add AdminLogin.vue with access code gate (20268888) - Add admin.ts store with state persistence - Add admin.types.ts with state machine types - Add router guards for /admin/director-console Voting System Fixes: - Add voting status check before accepting votes (VOTING_CLOSED error) - Fix client to display server error messages - Fix button disabled logic to prevent ambiguity in paused state - Auto-generate userId on connect to fix UNAUTHORIZED error Big Screen Enhancements: - Add LiveVotingView.vue with particle system - Add LotteryMachine.ts with 3-stage animation (Galaxy/Storm/Reveal) - Add useSocketClient.ts composable - Fix MainDisplay.vue SCSS syntax error - Add admin state sync listener in display store Server Updates: - Add admin.service.ts for state management - Add isVotingOpen() and getVotingStatus() methods - Add admin socket event handlers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
import { Application, Container, Graphics, Ticker } from 'pixi.js';
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const COLORS = {
|
|
gold: 0xf0c239,
|
|
goldLight: 0xffd700,
|
|
goldDark: 0xd4a84b,
|
|
red: 0xc21f30,
|
|
redLight: 0xe63946,
|
|
};
|
|
|
|
// Easing functions for smooth animation
|
|
const Easing = {
|
|
// Smooth acceleration then deceleration
|
|
easeInOutCubic: (t: number): number => {
|
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
},
|
|
// Fast start, slow end
|
|
easeOutQuart: (t: number): number => {
|
|
return 1 - Math.pow(1 - t, 4);
|
|
},
|
|
// Slow start, fast end
|
|
easeInQuad: (t: number): number => {
|
|
return t * t;
|
|
},
|
|
// Bounce at end
|
|
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);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface VoteParticle {
|
|
x: number;
|
|
y: number;
|
|
targetX: number;
|
|
targetY: number;
|
|
startX: number;
|
|
startY: number;
|
|
controlX: number;
|
|
controlY: number;
|
|
progress: number;
|
|
speed: number;
|
|
size: number;
|
|
alpha: number;
|
|
glowSize: number;
|
|
trailPoints: Array<{ x: number; y: number; alpha: number }>;
|
|
phase: 'flying' | 'impact' | 'done';
|
|
impactTime: number;
|
|
}
|
|
|
|
interface ImpactEffect {
|
|
x: number;
|
|
y: number;
|
|
particles: Array<{
|
|
angle: number;
|
|
speed: number;
|
|
distance: number;
|
|
size: number;
|
|
alpha: number;
|
|
}>;
|
|
progress: number;
|
|
phase: 'burst' | 'fade';
|
|
}
|
|
|
|
interface TargetGlow {
|
|
x: number;
|
|
y: number;
|
|
intensity: number;
|
|
pulsePhase: number;
|
|
baseIntensity: number;
|
|
}
|
|
|
|
export interface ProgramTarget {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
name: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// VoteParticleSystem Class
|
|
// ============================================================================
|
|
|
|
export class VoteParticleSystem {
|
|
private app: Application;
|
|
private container: Container;
|
|
private particleLayer: Graphics;
|
|
private glowLayer: Graphics;
|
|
private impactLayer: Graphics;
|
|
|
|
private particles: VoteParticle[] = [];
|
|
private impacts: ImpactEffect[] = [];
|
|
private targetGlows: Map<string, TargetGlow> = new Map();
|
|
private programTargets: Map<string, ProgramTarget> = new Map();
|
|
|
|
private isDestroyed = false;
|
|
private time = 0;
|
|
|
|
// Configuration
|
|
private readonly MAX_PARTICLES = 200;
|
|
private readonly TRAIL_LENGTH = 12;
|
|
private readonly PARTICLE_DURATION = 1500; // ms
|
|
private readonly IMPACT_DURATION = 600; // ms
|
|
|
|
constructor() {
|
|
this.app = new Application();
|
|
this.container = new Container();
|
|
this.particleLayer = new Graphics();
|
|
this.glowLayer = new Graphics();
|
|
this.impactLayer = new Graphics();
|
|
}
|
|
|
|
async init(canvas: HTMLCanvasElement): Promise<void> {
|
|
await this.app.init({
|
|
canvas,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
backgroundAlpha: 0,
|
|
antialias: true,
|
|
resolution: window.devicePixelRatio || 1,
|
|
autoDensity: true,
|
|
});
|
|
|
|
// Layer order: glow (back) -> particles -> impacts (front)
|
|
this.container.addChild(this.glowLayer);
|
|
this.container.addChild(this.particleLayer);
|
|
this.container.addChild(this.impactLayer);
|
|
this.app.stage.addChild(this.container);
|
|
|
|
// Start animation loop
|
|
this.app.ticker.add(this.update.bind(this));
|
|
|
|
// Handle resize
|
|
window.addEventListener('resize', this.handleResize.bind(this));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Public API
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Register a program target position for vote particles to fly towards
|
|
*/
|
|
registerTarget(target: ProgramTarget): void {
|
|
this.programTargets.set(target.id, target);
|
|
|
|
// Initialize glow state
|
|
if (!this.targetGlows.has(target.id)) {
|
|
this.targetGlows.set(target.id, {
|
|
x: target.x,
|
|
y: target.y,
|
|
intensity: 0,
|
|
pulsePhase: Math.random() * Math.PI * 2,
|
|
baseIntensity: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update target position (for responsive layouts)
|
|
*/
|
|
updateTargetPosition(id: string, x: number, y: number): void {
|
|
const target = this.programTargets.get(id);
|
|
if (target) {
|
|
target.x = x;
|
|
target.y = y;
|
|
}
|
|
|
|
const glow = this.targetGlows.get(id);
|
|
if (glow) {
|
|
glow.x = x;
|
|
glow.y = y;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawn a vote particle flying towards a program target
|
|
*/
|
|
spawnVoteParticle(targetId: string): void {
|
|
if (this.isDestroyed) return;
|
|
if (this.particles.length >= this.MAX_PARTICLES) return;
|
|
|
|
const target = this.programTargets.get(targetId);
|
|
if (!target) {
|
|
console.warn(`[VoteParticle] Unknown target: ${targetId}`);
|
|
return;
|
|
}
|
|
|
|
// Spawn from bottom edge with random horizontal position
|
|
const screenWidth = this.app.screen.width;
|
|
const screenHeight = this.app.screen.height;
|
|
|
|
const startX = Math.random() * screenWidth;
|
|
const startY = screenHeight + 20;
|
|
|
|
// Calculate bezier control point for curved trajectory
|
|
// Control point is offset to create an arc
|
|
const midX = (startX + target.x) / 2;
|
|
const midY = (startY + target.y) / 2;
|
|
const perpX = -(target.y - startY);
|
|
const perpY = target.x - startX;
|
|
const perpLen = Math.sqrt(perpX * perpX + perpY * perpY);
|
|
const curveAmount = (Math.random() - 0.5) * 300; // Random curve direction
|
|
|
|
const controlX = midX + (perpX / perpLen) * curveAmount;
|
|
const controlY = midY + (perpY / perpLen) * curveAmount;
|
|
|
|
const particle: VoteParticle = {
|
|
x: startX,
|
|
y: startY,
|
|
targetX: target.x,
|
|
targetY: target.y,
|
|
startX,
|
|
startY,
|
|
controlX,
|
|
controlY,
|
|
progress: 0,
|
|
speed: 0.8 + Math.random() * 0.4, // Vary speed slightly
|
|
size: 4 + Math.random() * 3,
|
|
alpha: 1,
|
|
glowSize: 15 + Math.random() * 10,
|
|
trailPoints: [],
|
|
phase: 'flying',
|
|
impactTime: 0,
|
|
};
|
|
|
|
this.particles.push(particle);
|
|
}
|
|
|
|
/**
|
|
* Spawn multiple particles at once (for burst effect)
|
|
*/
|
|
spawnVoteParticleBurst(targetId: string, count: number = 3): void {
|
|
for (let i = 0; i < count; i++) {
|
|
setTimeout(() => {
|
|
this.spawnVoteParticle(targetId);
|
|
}, i * 50); // Stagger spawns
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increase heat/glow intensity for a target
|
|
*/
|
|
addHeat(targetId: string, amount: number = 0.3): void {
|
|
const glow = this.targetGlows.get(targetId);
|
|
if (glow) {
|
|
glow.baseIntensity = Math.min(1, glow.baseIntensity + amount);
|
|
glow.intensity = Math.min(1.5, glow.intensity + amount * 2); // Pulse effect
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Animation Loop
|
|
// ============================================================================
|
|
|
|
private update(ticker: Ticker): void {
|
|
if (this.isDestroyed) return;
|
|
|
|
this.time += ticker.deltaMS;
|
|
|
|
// Clear all layers
|
|
this.particleLayer.clear();
|
|
this.glowLayer.clear();
|
|
this.impactLayer.clear();
|
|
|
|
// Update and draw target glows
|
|
this.updateTargetGlows(ticker.deltaMS);
|
|
|
|
// Update and draw particles
|
|
this.updateParticles(ticker.deltaMS);
|
|
|
|
// Update and draw impact effects
|
|
this.updateImpacts(ticker.deltaMS);
|
|
}
|
|
|
|
private updateTargetGlows(deltaMs: number): void {
|
|
this.targetGlows.forEach((glow, id) => {
|
|
// Decay intensity over time
|
|
glow.intensity = Math.max(glow.baseIntensity, glow.intensity - deltaMs * 0.002);
|
|
glow.baseIntensity = Math.max(0, glow.baseIntensity - deltaMs * 0.0002);
|
|
|
|
// Pulse animation
|
|
glow.pulsePhase += deltaMs * 0.003;
|
|
const pulse = Math.sin(glow.pulsePhase) * 0.1 + 1;
|
|
|
|
if (glow.intensity > 0.01) {
|
|
this.drawTargetGlow(glow, pulse);
|
|
}
|
|
});
|
|
}
|
|
|
|
private drawTargetGlow(glow: TargetGlow, pulse: number): void {
|
|
const intensity = glow.intensity * pulse;
|
|
const maxRadius = 60 + intensity * 40;
|
|
|
|
// Draw multiple concentric circles for glow effect
|
|
for (let i = 5; i >= 0; i--) {
|
|
const ratio = i / 5;
|
|
const radius = maxRadius * (0.3 + ratio * 0.7);
|
|
const alpha = intensity * (1 - ratio) * 0.4;
|
|
|
|
this.glowLayer.circle(glow.x, glow.y, radius);
|
|
this.glowLayer.fill({ color: COLORS.gold, alpha });
|
|
}
|
|
|
|
// Inner bright core
|
|
this.glowLayer.circle(glow.x, glow.y, 8 + intensity * 5);
|
|
this.glowLayer.fill({ color: COLORS.goldLight, alpha: intensity * 0.8 });
|
|
}
|
|
|
|
private updateParticles(deltaMs: number): void {
|
|
this.particles = this.particles.filter((p) => {
|
|
if (p.phase === 'flying') {
|
|
// Update progress along bezier curve
|
|
p.progress += (deltaMs / this.PARTICLE_DURATION) * p.speed;
|
|
|
|
if (p.progress >= 1) {
|
|
// Reached target - trigger impact
|
|
p.phase = 'impact';
|
|
p.impactTime = 0;
|
|
this.triggerImpact(p.targetX, p.targetY);
|
|
|
|
// Find target and add heat
|
|
for (const [id, target] of this.programTargets) {
|
|
if (target.x === p.targetX && target.y === p.targetY) {
|
|
this.addHeat(id);
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Calculate position on quadratic bezier curve with easing
|
|
const t = Easing.easeInOutCubic(p.progress);
|
|
const invT = 1 - t;
|
|
|
|
p.x = invT * invT * p.startX + 2 * invT * t * p.controlX + t * t * p.targetX;
|
|
p.y = invT * invT * p.startY + 2 * invT * t * p.controlY + t * t * p.targetY;
|
|
|
|
// Update trail
|
|
p.trailPoints.unshift({ x: p.x, y: p.y, alpha: 1 });
|
|
if (p.trailPoints.length > this.TRAIL_LENGTH) {
|
|
p.trailPoints.pop();
|
|
}
|
|
|
|
// Fade trail points
|
|
p.trailPoints.forEach((point, i) => {
|
|
point.alpha = 1 - i / this.TRAIL_LENGTH;
|
|
});
|
|
|
|
// Draw particle
|
|
this.drawParticle(p);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
private drawParticle(p: VoteParticle): void {
|
|
// Draw trail
|
|
p.trailPoints.forEach((point, i) => {
|
|
const size = p.size * (1 - i / this.TRAIL_LENGTH * 0.7);
|
|
const alpha = point.alpha * 0.6;
|
|
|
|
this.particleLayer.circle(point.x, point.y, size);
|
|
this.particleLayer.fill({ color: COLORS.gold, alpha });
|
|
});
|
|
|
|
// Draw glow around particle
|
|
for (let i = 3; i >= 0; i--) {
|
|
const ratio = i / 3;
|
|
const radius = p.glowSize * (0.3 + ratio * 0.7);
|
|
const alpha = (1 - ratio) * 0.3 * p.alpha;
|
|
|
|
this.particleLayer.circle(p.x, p.y, radius);
|
|
this.particleLayer.fill({ color: COLORS.gold, alpha });
|
|
}
|
|
|
|
// Draw bright core
|
|
this.particleLayer.circle(p.x, p.y, p.size);
|
|
this.particleLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
|
|
|
|
// Inner white hot center
|
|
this.particleLayer.circle(p.x, p.y, p.size * 0.5);
|
|
this.particleLayer.fill({ color: 0xffffff, alpha: p.alpha * 0.8 });
|
|
}
|
|
|
|
private triggerImpact(x: number, y: number): void {
|
|
const particleCount = 12 + Math.floor(Math.random() * 8);
|
|
const impactParticles: ImpactEffect['particles'] = [];
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / particleCount + Math.random() * 0.3;
|
|
impactParticles.push({
|
|
angle,
|
|
speed: 2 + Math.random() * 3,
|
|
distance: 0,
|
|
size: 2 + Math.random() * 3,
|
|
alpha: 1,
|
|
});
|
|
}
|
|
|
|
this.impacts.push({
|
|
x,
|
|
y,
|
|
particles: impactParticles,
|
|
progress: 0,
|
|
phase: 'burst',
|
|
});
|
|
}
|
|
|
|
private updateImpacts(deltaMs: number): void {
|
|
this.impacts = this.impacts.filter((impact) => {
|
|
impact.progress += deltaMs / this.IMPACT_DURATION;
|
|
|
|
if (impact.progress >= 1) {
|
|
return false;
|
|
}
|
|
|
|
// Update impact particles
|
|
const t = Easing.easeOutQuart(impact.progress);
|
|
|
|
impact.particles.forEach((p) => {
|
|
p.distance = t * 50 * p.speed;
|
|
p.alpha = 1 - Easing.easeInQuad(impact.progress);
|
|
p.size = p.size * (1 + t * 0.5);
|
|
});
|
|
|
|
// Draw impact
|
|
this.drawImpact(impact);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private drawImpact(impact: ImpactEffect): void {
|
|
// Draw central flash
|
|
const flashAlpha = (1 - impact.progress) * 0.8;
|
|
const flashSize = 20 + impact.progress * 30;
|
|
|
|
for (let i = 3; i >= 0; i--) {
|
|
const ratio = i / 3;
|
|
const radius = flashSize * (0.3 + ratio * 0.7);
|
|
const alpha = flashAlpha * (1 - ratio);
|
|
|
|
this.impactLayer.circle(impact.x, impact.y, radius);
|
|
this.impactLayer.fill({ color: COLORS.goldLight, alpha });
|
|
}
|
|
|
|
// Draw burst particles
|
|
impact.particles.forEach((p) => {
|
|
const px = impact.x + Math.cos(p.angle) * p.distance;
|
|
const py = impact.y + Math.sin(p.angle) * p.distance;
|
|
|
|
// Particle glow
|
|
this.impactLayer.circle(px, py, p.size * 2);
|
|
this.impactLayer.fill({ color: COLORS.gold, alpha: p.alpha * 0.3 });
|
|
|
|
// Particle core
|
|
this.impactLayer.circle(px, py, p.size);
|
|
this.impactLayer.fill({ color: COLORS.goldLight, alpha: p.alpha });
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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.particles = [];
|
|
this.impacts = [];
|
|
this.targetGlows.clear();
|
|
this.programTargets.clear();
|
|
this.app.destroy(true, { children: true });
|
|
}
|
|
}
|