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>
This commit is contained in:
492
packages/client-screen/src/pixi/VoteParticleSystem.ts
Normal file
492
packages/client-screen/src/pixi/VoteParticleSystem.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user