Files
company-celebration/packages/client-screen/src/pixi/VoteParticleSystem.ts
empty 30cd29d45d feat: add Admin Control Panel, voting status check, and router security
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>
2026-01-15 15:34:37 +08:00

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