diff --git a/packages/client-mobile/src/assets/styles/global.scss b/packages/client-mobile/src/assets/styles/global.scss index fd9ce64..2aedb41 100644 --- a/packages/client-mobile/src/assets/styles/global.scss +++ b/packages/client-mobile/src/assets/styles/global.scss @@ -1,4 +1,4 @@ -@import './variables.scss'; +@use './variables.scss' as *; // Reset *, diff --git a/packages/client-mobile/src/assets/styles/variables.scss b/packages/client-mobile/src/assets/styles/variables.scss index 597c825..a030d2e 100644 --- a/packages/client-mobile/src/assets/styles/variables.scss +++ b/packages/client-mobile/src/assets/styles/variables.scss @@ -1,13 +1,20 @@ // Guochao Red & Gold Theme Variables +// Year of the Horse Edition - 马年国潮主题 -// Primary colors - 国潮红金配色 -$color-primary: #c41230; // 中国红 +// Primary colors - 丹砂红 +$color-primary: #C21F30; // Dan-sha Red $color-primary-light: #e8384f; $color-primary-dark: #9a0e26; -$color-gold: #d4a84b; // 金色 -$color-gold-light: #f0c96a; -$color-gold-dark: #b8923f; +// Gold colors - 香槟金 +$color-gold: #F0C239; // Champagne Gold +$color-gold-light: #f5d76e; +$color-gold-dark: #d4a84b; + +// Glassmorphism surfaces +$color-surface-glass: rgba(194, 31, 48, 0.85); +$color-surface-glass-light: rgba(194, 31, 48, 0.6); +$backdrop-blur: blur(12px); // Background colors $color-bg-primary: #fff5f5; diff --git a/packages/client-mobile/src/components.d.ts b/packages/client-mobile/src/components.d.ts index 8cddc8e..74eccf3 100644 --- a/packages/client-mobile/src/components.d.ts +++ b/packages/client-mobile/src/components.d.ts @@ -8,7 +8,18 @@ export {} declare module 'vue' { export interface GlobalComponents { ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default'] + Postmark: typeof import('./components/Postmark.vue')['default'] + ProgramCard: typeof import('./components/ProgramCard.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + StampDock: typeof import('./components/StampDock.vue')['default'] + VanButton: typeof import('vant/es')['Button'] + VanCell: typeof import('vant/es')['Cell'] + VanCellGroup: typeof import('vant/es')['CellGroup'] + VanEmpty: typeof import('vant/es')['Empty'] + VanField: typeof import('vant/es')['Field'] + VanIcon: typeof import('vant/es')['Icon'] + VanNavBar: typeof import('vant/es')['NavBar'] + VotingDock: typeof import('./components/VotingDock.vue')['default'] } } diff --git a/packages/client-mobile/src/components/ConnectionStatus.vue b/packages/client-mobile/src/components/ConnectionStatus.vue index b9bc977..c42ea24 100644 --- a/packages/client-mobile/src/components/ConnectionStatus.vue +++ b/packages/client-mobile/src/components/ConnectionStatus.vue @@ -38,7 +38,7 @@ const statusText = computed(() => { diff --git a/packages/client-mobile/src/components/ProgramCard.vue b/packages/client-mobile/src/components/ProgramCard.vue new file mode 100644 index 0000000..74b4f90 --- /dev/null +++ b/packages/client-mobile/src/components/ProgramCard.vue @@ -0,0 +1,463 @@ + + + + + diff --git a/packages/client-mobile/src/components/StampDock.vue b/packages/client-mobile/src/components/StampDock.vue new file mode 100644 index 0000000..6c4e89e --- /dev/null +++ b/packages/client-mobile/src/components/StampDock.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/packages/client-mobile/src/components/VotingDock.vue b/packages/client-mobile/src/components/VotingDock.vue new file mode 100644 index 0000000..0b1021a --- /dev/null +++ b/packages/client-mobile/src/components/VotingDock.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/packages/client-mobile/src/router/index.ts b/packages/client-mobile/src/router/index.ts index 8e7435a..d743f48 100644 --- a/packages/client-mobile/src/router/index.ts +++ b/packages/client-mobile/src/router/index.ts @@ -13,11 +13,6 @@ const router = createRouter({ name: 'vote', component: () => import('../views/VoteView.vue'), }, - { - path: '/vote/:category', - name: 'vote-category', - component: () => import('../views/VoteCategoryView.vue'), - }, { path: '/result', name: 'result', diff --git a/packages/client-mobile/src/stores/connection.ts b/packages/client-mobile/src/stores/connection.ts index 161a4d6..673e58c 100644 --- a/packages/client-mobile/src/stores/connection.ts +++ b/packages/client-mobile/src/stores/connection.ts @@ -67,6 +67,12 @@ export const useConnectionStore = defineStore('connection', () => { isConnecting.value = false; reconnectAttempts.value = 0; + // Auto-generate userId if not set + if (!userId.value) { + userId.value = `user_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + userName.value = '访客'; + } + // Join with user info joinRoom(); diff --git a/packages/client-mobile/src/stores/voting.ts b/packages/client-mobile/src/stores/voting.ts new file mode 100644 index 0000000..9bf2751 --- /dev/null +++ b/packages/client-mobile/src/stores/voting.ts @@ -0,0 +1,229 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { useConnectionStore } from './connection'; +import { TICKET_TYPES, type TicketType } from '@gala/shared/constants'; +import { showToast } from 'vant'; + +// Ticket display info +export const TICKET_INFO: Record = { + creative: { name: '最佳创意', icon: 'creative' }, + visual: { name: '最佳视觉', icon: 'visual' }, + atmosphere: { name: '最佳氛围', icon: 'atmosphere' }, + performance: { name: '最佳表演', icon: 'performance' }, + teamwork: { name: '最佳团队', icon: 'teamwork' }, + popularity: { name: '最受欢迎', icon: 'popularity' }, + potential: { name: '最具潜力', icon: 'potential' }, +}; + +interface PendingVote { + ticketType: TicketType; + programId: string; + timestamp: number; +} + +export const useVotingStore = defineStore('voting', () => { + const connectionStore = useConnectionStore(); + + // State: ticketType -> programId (null if unused) + const tickets = ref>({ + creative: null, + visual: null, + atmosphere: null, + performance: null, + teamwork: null, + popularity: null, + potential: null, + }); + + // Currently selected stamp in dock + const selectedStamp = ref(null); + + // Pending votes (optimistic updates waiting for server confirmation) + const pendingVotes = ref>(new Map()); + + // Animation state for cards + const stampingCard = ref(null); + + // Computed + const usedTickets = computed(() => { + return TICKET_TYPES.filter((t) => tickets.value[t] !== null); + }); + + const availableTickets = computed(() => { + return TICKET_TYPES.filter((t) => tickets.value[t] === null); + }); + + const isStampSelected = computed(() => selectedStamp.value !== null); + + // Check if a program has received any stamp from this user + function getProgramStamp(programId: string): TicketType | null { + for (const ticketType of TICKET_TYPES) { + if (tickets.value[ticketType] === programId) { + return ticketType; + } + } + return null; + } + + // Select a stamp from the dock + function selectStamp(ticketType: TicketType) { + if (tickets.value[ticketType] !== null) { + showToast({ message: '该印章已使用', position: 'bottom' }); + return; + } + selectedStamp.value = ticketType; + } + + // Deselect stamp + function deselectStamp() { + selectedStamp.value = null; + } + + // Trigger haptic feedback + function triggerHaptic() { + if ('vibrate' in navigator) { + navigator.vibrate(50); + } + } + + // Cast vote with optimistic update + async function castVote(programId: string): Promise { + const ticketType = selectedStamp.value; + if (!ticketType) { + showToast({ message: '请先选择一个印章', position: 'bottom' }); + return false; + } + + // Check if already voted for this program + const existingStamp = getProgramStamp(programId); + if (existingStamp) { + showToast({ message: '已为该节目投过票', position: 'bottom' }); + return false; + } + + // Optimistic update + const previousValue = tickets.value[ticketType]; + tickets.value[ticketType] = programId; + stampingCard.value = programId; + + // Trigger haptic + triggerHaptic(); + + // Create pending vote record + const voteKey = `${ticketType}:${programId}`; + pendingVotes.value.set(voteKey, { + ticketType, + programId, + timestamp: Date.now(), + }); + + // Clear selection + selectedStamp.value = null; + + // Call backend + try { + const socket = connectionStore.getSocket(); + if (!socket) { + throw new Error('Not connected'); + } + + const localId = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + const result = await new Promise<{ success: boolean; error?: string; message?: string }>((resolve) => { + const timeoutId = setTimeout(() => { + resolve({ success: false, error: 'TIMEOUT', message: '网络超时,请重试' }); + }, 10000); + + socket.emit('vote:submit' as any, { + candidateId: programId, + category: ticketType, + clientTimestamp: Date.now(), + localId, + }, (response: any) => { + clearTimeout(timeoutId); + resolve(response); + }); + }); + + if (!result.success) { + // Use server message if available + const errorMessage = result.message || '投票失败,请重试'; + throw new Error(errorMessage); + } + + // Success - remove from pending + pendingVotes.value.delete(voteKey); + return true; + + } catch (error) { + // Revert optimistic update + tickets.value[ticketType] = previousValue; + pendingVotes.value.delete(voteKey); + + const errorMessage = error instanceof Error ? error.message : '投票失败,请重试'; + showToast({ + message: errorMessage, + position: 'bottom', + type: 'fail', + }); + return false; + + } finally { + // Clear animation state after delay + setTimeout(() => { + stampingCard.value = null; + }, 600); + } + } + + // Revoke a vote + async function revokeVote(ticketType: TicketType): Promise { + const programId = tickets.value[ticketType]; + if (!programId) return false; + + // Optimistic update + tickets.value[ticketType] = null; + + try { + const socket = connectionStore.getSocket(); + if (!socket) throw new Error('Not connected'); + + const result = await new Promise<{ success: boolean }>((resolve) => { + socket.emit('vote:revoke' as any, { ticketType }, (response: any) => { + resolve(response); + }); + setTimeout(() => resolve({ success: false }), 10000); + }); + + if (!result.success) throw new Error('Revoke failed'); + return true; + + } catch { + // Revert + tickets.value[ticketType] = programId; + showToast({ message: '撤销失败', position: 'bottom', type: 'fail' }); + return false; + } + } + + // Sync state from server + function syncFromServer(serverTickets: Record) { + tickets.value = { ...serverTickets }; + } + + return { + tickets, + selectedStamp, + pendingVotes, + stampingCard, + usedTickets, + availableTickets, + isStampSelected, + getProgramStamp, + selectStamp, + deselectStamp, + castVote, + revokeVote, + syncFromServer, + }; +}); diff --git a/packages/client-mobile/src/views/HomeView.vue b/packages/client-mobile/src/views/HomeView.vue index 296412d..b43c5f9 100644 --- a/packages/client-mobile/src/views/HomeView.vue +++ b/packages/client-mobile/src/views/HomeView.vue @@ -100,7 +100,7 @@ async function handleEnter() { diff --git a/packages/client-mobile/src/views/VotingPage.vue b/packages/client-mobile/src/views/VotingPage.vue new file mode 100644 index 0000000..4db37d4 --- /dev/null +++ b/packages/client-mobile/src/views/VotingPage.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/client-screen/src/App.vue b/packages/client-screen/src/App.vue index 28bd995..0a900f2 100644 --- a/packages/client-screen/src/App.vue +++ b/packages/client-screen/src/App.vue @@ -1,10 +1,21 @@ diff --git a/packages/client-screen/src/assets/styles/global.scss b/packages/client-screen/src/assets/styles/global.scss index 2898e44..bc110fd 100644 --- a/packages/client-screen/src/assets/styles/global.scss +++ b/packages/client-screen/src/assets/styles/global.scss @@ -1,4 +1,4 @@ -@import './variables.scss'; +@use './variables.scss' as *; * { margin: 0; @@ -25,6 +25,35 @@ html, body { background-clip: text; } +// Liquid Gold shimmer effect +.liquid-gold { + font-family: 'Noto Serif SC', 'Ma Shan Zheng', serif; + background: linear-gradient( + 90deg, + $color-gold-dark 0%, + $color-gold 25%, + $color-gold-light 50%, + $color-gold 75%, + $color-gold-dark 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: shimmer 3s ease-in-out infinite; + text-shadow: none; + filter: drop-shadow(0 0 20px rgba($color-gold, 0.4)); +} + +@keyframes shimmer { + 0%, 100% { + background-position: 200% center; + } + 50% { + background-position: 0% center; + } +} + // Red glow text .glow-text { text-shadow: $glow-red; diff --git a/packages/client-screen/src/assets/styles/variables.scss b/packages/client-screen/src/assets/styles/variables.scss index 98fda8d..d124137 100644 --- a/packages/client-screen/src/assets/styles/variables.scss +++ b/packages/client-screen/src/assets/styles/variables.scss @@ -1,17 +1,20 @@ -// Guochao Red & Gold Theme for Big Screen +// Guochao Red & Gold Theme for Big Screen - Cinematic Edition -// Primary colors -$color-primary: #c41230; +// Primary colors (Dan-sha Red) +$color-primary: #c21f30; $color-primary-light: #e8384f; $color-primary-dark: #9a0e26; -$color-gold: #d4a84b; -$color-gold-light: #f0c96a; -$color-gold-dark: #b8923f; +// Gold palette (Champagne Gold) +$color-gold: #f0c239; +$color-gold-light: #ffd966; +$color-gold-dark: #d4a84b; +$color-gold-deep: #b8923f; -// Background +// Cinematic background $color-bg-dark: #0a0a0a; -$color-bg-gradient: linear-gradient(180deg, #1a0a0a 0%, #0a0a0a 50%, #0a0510 100%); +$color-bg-wine: #2a0a0e; +$color-bg-gradient: linear-gradient(180deg, #2a0a0e 0%, #1a0808 50%, #0a0505 100%); // Text $color-text-light: #ffffff; diff --git a/packages/client-screen/src/composables/useSocketClient.ts b/packages/client-screen/src/composables/useSocketClient.ts new file mode 100644 index 0000000..6713694 --- /dev/null +++ b/packages/client-screen/src/composables/useSocketClient.ts @@ -0,0 +1,283 @@ +import { ref, shallowRef, onMounted, onUnmounted, readonly } from 'vue'; +import { io, Socket } from 'socket.io-client'; +import type { + ServerToClientEvents, + ClientToServerEvents, + DrawStartPayload, + DrawSpinPayload, + DrawWinnerPayload, + VoteUpdatePayload, +} from '@gala/shared/types'; +import { SOCKET_EVENTS } from '@gala/shared/constants'; + +type GalaSocket = Socket; + +// ============================================================================ +// Types +// ============================================================================ + +export interface LotteryState { + phase: 'idle' | 'rolling' | 'revealing' | 'complete'; + prizeLevel: string | null; + prizeName: string | null; + participantCount: number; + winners: Array<{ + odrawId: string; + visitorId: string; + userId: string; + userName: string; + department: string; + avatar?: string; + }>; + spinningNames: string[]; +} + +export interface VoteEvent { + candidateId: string; + category: string; + totalVotes: number; + delta: number; + timestamp: number; +} + +export type VoteUpdateCallback = (event: VoteEvent) => void; +export type LotteryStateCallback = (state: LotteryState) => void; + +// ============================================================================ +// Composable +// ============================================================================ + +export function useSocketClient() { + // Connection state + const socket = shallowRef(null); + const isConnected = ref(false); + const isConnecting = ref(false); + const onlineUsers = ref(0); + const latency = ref(0); + + // Lottery state + const lotteryState = ref({ + phase: 'idle', + prizeLevel: null, + prizeName: null, + participantCount: 0, + winners: [], + spinningNames: [], + }); + + // Event callbacks + const voteCallbacks = new Set(); + const lotteryCallbacks = new Set(); + + // Heartbeat + let heartbeatInterval: ReturnType | null = null; + let lastPingTime = 0; + + // ============================================================================ + // Connection Management + // ============================================================================ + + function connect(): void { + if (socket.value?.connected || isConnecting.value) { + return; + } + + isConnecting.value = true; + + const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', { + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 10000, + transports: ['websocket', 'polling'], + }); + + // Connection events + socketInstance.on('connect', () => { + console.log('[BigScreen] Connected to server'); + isConnected.value = true; + isConnecting.value = false; + + // Join as screen display + socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, { + userId: `screen_${Date.now()}`, + userName: 'Big Screen Display', + role: 'screen', + }, () => {}); + + // Start heartbeat + startHeartbeat(); + }); + + socketInstance.on('disconnect', (reason) => { + console.log('[BigScreen] Disconnected:', reason); + isConnected.value = false; + stopHeartbeat(); + }); + + socketInstance.on('connect_error', (error) => { + console.error('[BigScreen] Connection error:', error); + isConnecting.value = false; + }); + + // User count updates + socketInstance.on('connection:users_count' as any, (count: number) => { + onlineUsers.value = count; + }); + + // Pong response + socketInstance.on('connection:pong' as any, () => { + latency.value = Date.now() - lastPingTime; + }); + + // Vote updates + socketInstance.on('vote:updated' as any, (data: VoteUpdatePayload) => { + const event: VoteEvent = { + ...data, + timestamp: Date.now(), + }; + + // Notify all registered callbacks + voteCallbacks.forEach(cb => cb(event)); + }); + + // Lottery events + socketInstance.on('draw:started' as any, (data: DrawStartPayload) => { + lotteryState.value = { + phase: 'rolling', + prizeLevel: data.prizeLevel, + prizeName: data.prizeName, + participantCount: data.participantCount, + winners: [], + spinningNames: [], + }; + notifyLotteryCallbacks(); + }); + + socketInstance.on('draw:spinning' as any, (data: DrawSpinPayload) => { + lotteryState.value.spinningNames = data.currentNames; + lotteryState.value.phase = 'rolling'; + notifyLotteryCallbacks(); + }); + + socketInstance.on('draw:winner' as any, (data: DrawWinnerPayload) => { + lotteryState.value.phase = 'revealing'; + lotteryState.value.winners.push(data.winner); + notifyLotteryCallbacks(); + }); + + socketInstance.on('draw:ended' as any, () => { + lotteryState.value.phase = 'complete'; + notifyLotteryCallbacks(); + + // Reset to idle after delay + setTimeout(() => { + lotteryState.value.phase = 'idle'; + notifyLotteryCallbacks(); + }, 10000); + }); + + socket.value = socketInstance as GalaSocket; + } + + function disconnect(): void { + stopHeartbeat(); + if (socket.value) { + socket.value.disconnect(); + socket.value = null; + } + isConnected.value = false; + isConnecting.value = false; + } + + // ============================================================================ + // Heartbeat + // ============================================================================ + + function startHeartbeat(): void { + stopHeartbeat(); + heartbeatInterval = setInterval(() => { + if (socket.value?.connected) { + lastPingTime = Date.now(); + socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any); + } + }, 5000); + } + + function stopHeartbeat(): void { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + } + + // ============================================================================ + // Event Subscription + // ============================================================================ + + function onVoteUpdate(callback: VoteUpdateCallback): () => void { + voteCallbacks.add(callback); + return () => voteCallbacks.delete(callback); + } + + function onLotteryStateChange(callback: LotteryStateCallback): () => void { + lotteryCallbacks.add(callback); + return () => lotteryCallbacks.delete(callback); + } + + function notifyLotteryCallbacks(): void { + lotteryCallbacks.forEach(cb => cb(lotteryState.value)); + } + + // ============================================================================ + // Admin Controls (for testing) + // ============================================================================ + + function startLottery(prizeLevel: string): void { + if (!socket.value?.connected) return; + + socket.value.emit('draw:start' as any, { + prizeLevel, + filters: {}, + }, () => {}); + } + + function stopLottery(): void { + if (!socket.value?.connected) return; + + socket.value.emit('draw:stop' as any, () => {}); + } + + // ============================================================================ + // Lifecycle + // ============================================================================ + + onMounted(() => { + connect(); + }); + + onUnmounted(() => { + disconnect(); + }); + + return { + // State (readonly) + isConnected: readonly(isConnected), + isConnecting: readonly(isConnecting), + onlineUsers: readonly(onlineUsers), + latency: readonly(latency), + lotteryState: readonly(lotteryState), + + // Methods + connect, + disconnect, + onVoteUpdate, + onLotteryStateChange, + startLottery, + stopLottery, + + // Socket access (for advanced usage) + getSocket: () => socket.value, + }; +} diff --git a/packages/client-screen/src/pixi/BackgroundEffect.ts b/packages/client-screen/src/pixi/BackgroundEffect.ts new file mode 100644 index 0000000..d6da4ee --- /dev/null +++ b/packages/client-screen/src/pixi/BackgroundEffect.ts @@ -0,0 +1,283 @@ +import { Application, Container, Graphics, Ticker } from 'pixi.js'; + +// Cinematic color palette +const COLORS = { + wineRed: 0x2a0a0e, + gold: 0xf0c239, + goldDark: 0xd4a84b, + goldLight: 0xffd700, +}; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + alpha: number; + rotation: number; + rotationSpeed: number; + type: 'dust' | 'symbol' | 'streak'; + life: number; + maxLife: number; +} + +export class BackgroundEffect { + private app: Application; + private container: Container; + private particles: Particle[] = []; + private dustLayer: Graphics; + private symbolLayer: Graphics; + private streakLayer: Graphics; + private time = 0; + private windOffset = 0; + private isDestroyed = false; + + // Configuration + private readonly DUST_COUNT = 120; + private readonly SYMBOL_COUNT = 25; + private readonly STREAK_INTERVAL = 3000; // ms between streaks + + constructor() { + this.app = new Application(); + this.container = new Container(); + this.dustLayer = new Graphics(); + this.symbolLayer = new Graphics(); + this.streakLayer = new Graphics(); + } + + async init(canvas: HTMLCanvasElement): Promise { + await this.app.init({ + canvas, + width: window.innerWidth, + height: window.innerHeight, + backgroundColor: COLORS.wineRed, + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + }); + + // Setup layers (back to front) + this.container.addChild(this.dustLayer); + this.container.addChild(this.symbolLayer); + this.container.addChild(this.streakLayer); + this.app.stage.addChild(this.container); + + // Draw vignette overlay + this.drawVignette(); + + // Initialize particles + this.initDustParticles(); + this.initSymbolParticles(); + + // Start animation loop + this.app.ticker.add(this.update.bind(this)); + + // Spawn streaks periodically + this.spawnStreakLoop(); + + // Handle resize + window.addEventListener('resize', this.handleResize.bind(this)); + } + + private drawVignette(): void { + const vignette = new Graphics(); + const w = this.app.screen.width; + const h = this.app.screen.height; + const cx = w / 2; + const cy = h / 2; + const maxRadius = Math.sqrt(cx * cx + cy * cy); + + // Create radial gradient effect with multiple circles + for (let i = 20; i >= 0; i--) { + const ratio = i / 20; + const radius = maxRadius * (0.3 + ratio * 0.7); + const alpha = ratio * 0.8; + + vignette.circle(cx, cy, radius); + vignette.fill({ color: 0x000000, alpha }); + } + + this.app.stage.addChildAt(vignette, 0); + } + + private initDustParticles(): void { + const w = this.app.screen.width; + const h = this.app.screen.height; + + for (let i = 0; i < this.DUST_COUNT; i++) { + this.particles.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.2, + size: Math.random() * 2 + 1, + alpha: Math.random() * 0.3 + 0.1, + rotation: 0, + rotationSpeed: 0, + type: 'dust', + life: 0, + maxLife: Infinity, + }); + } + } + + private initSymbolParticles(): void { + const w = this.app.screen.width; + const h = this.app.screen.height; + + for (let i = 0; i < this.SYMBOL_COUNT; i++) { + this.particles.push({ + x: Math.random() * w, + y: h + Math.random() * 100, + vx: (Math.random() - 0.5) * 0.5, + vy: -Math.random() * 0.8 - 0.3, + size: Math.random() * 8 + 4, + alpha: Math.random() * 0.4 + 0.2, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.02, + type: 'symbol', + life: 0, + maxLife: Infinity, + }); + } + } + + private spawnStreak(): void { + if (this.isDestroyed) return; + + const w = this.app.screen.width; + const h = this.app.screen.height; + const startX = Math.random() * w * 0.3; + const startY = Math.random() * h * 0.3; + + this.particles.push({ + x: startX, + y: startY, + vx: Math.random() * 8 + 6, + vy: Math.random() * 4 + 3, + size: Math.random() * 3 + 2, + alpha: 0.9, + rotation: Math.atan2(4, 8), + rotationSpeed: 0, + type: 'streak', + life: 0, + maxLife: 60, // frames + }); + } + + private spawnStreakLoop(): void { + if (this.isDestroyed) return; + + this.spawnStreak(); + setTimeout(() => this.spawnStreakLoop(), this.STREAK_INTERVAL + Math.random() * 2000); + } + + private update(ticker: Ticker): void { + if (this.isDestroyed) return; + + this.time += ticker.deltaTime; + this.windOffset = Math.sin(this.time * 0.01) * 0.5; + + // Clear graphics + this.dustLayer.clear(); + this.symbolLayer.clear(); + this.streakLayer.clear(); + + const w = this.app.screen.width; + const h = this.app.screen.height; + + // Update and draw particles + this.particles = this.particles.filter((p) => { + // Update position with wind + p.x += p.vx + this.windOffset * (p.type === 'dust' ? 0.3 : 0.1); + p.y += p.vy; + p.rotation += p.rotationSpeed; + p.life++; + + // Check bounds and lifecycle + if (p.type === 'streak') { + if (p.life >= p.maxLife || p.x > w || p.y > h) { + return false; + } + p.alpha = 0.9 * (1 - p.life / p.maxLife); + } else if (p.type === 'symbol') { + // Reset symbols that go off top + if (p.y < -20) { + p.y = h + 20; + p.x = Math.random() * w; + } + } else { + // Wrap dust particles + if (p.x < 0) p.x = w; + if (p.x > w) p.x = 0; + if (p.y < 0) p.y = h; + if (p.y > h) p.y = 0; + } + + // Draw particle + this.drawParticle(p); + return true; + }); + } + + private drawParticle(p: Particle): void { + const layer = p.type === 'dust' + ? this.dustLayer + : p.type === 'symbol' + ? this.symbolLayer + : this.streakLayer; + + if (p.type === 'dust') { + // Simple gold circle + layer.circle(p.x, p.y, p.size); + layer.fill({ color: COLORS.gold, alpha: p.alpha }); + } else if (p.type === 'symbol') { + // Fortune symbols (circles and squares) + layer.setTransform(p.x, p.y, 1, 1, p.rotation); + + if (Math.random() > 0.5) { + // Circle + layer.circle(0, 0, p.size); + } else { + // Square/diamond + layer.rect(-p.size / 2, -p.size / 2, p.size, p.size); + } + layer.fill({ color: COLORS.goldDark, alpha: p.alpha }); + layer.setTransform(0, 0, 1, 1, 0); + } else if (p.type === 'streak') { + // Gold streak with trail + const trailLength = 80; + const dx = Math.cos(p.rotation); + const dy = Math.sin(p.rotation); + + // Draw gradient trail + for (let i = 0; i < 10; i++) { + const ratio = i / 10; + const tx = p.x - dx * trailLength * ratio; + const ty = p.y - dy * trailLength * ratio; + const ta = p.alpha * (1 - ratio) * 0.5; + const ts = p.size * (1 - ratio * 0.5); + + layer.circle(tx, ty, ts); + layer.fill({ color: COLORS.goldLight, alpha: ta }); + } + + // Draw head + layer.circle(p.x, p.y, p.size); + layer.fill({ color: COLORS.goldLight, alpha: p.alpha }); + } + } + + 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.app.destroy(true, { children: true }); + } +} diff --git a/packages/client-screen/src/pixi/LotteryMachine.ts b/packages/client-screen/src/pixi/LotteryMachine.ts new file mode 100644 index 0000000..82f4c45 --- /dev/null +++ b/packages/client-screen/src/pixi/LotteryMachine.ts @@ -0,0 +1,752 @@ +import { Application, Container, Graphics, Text, TextStyle, Ticker, BlurFilter } from 'pixi.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +const COLORS = { + gold: 0xf0c239, + goldLight: 0xffd700, + goldDark: 0xd4a84b, + red: 0xc21f30, + redLight: 0xe63946, + redDark: 0x8b0000, + wineRed: 0x2a0a0e, + black: 0x000000, + white: 0xffffff, +}; + +// Easing functions +const Easing = { + easeInOutCubic: (t: number): number => { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + }, + easeOutQuart: (t: number): number => { + return 1 - Math.pow(1 - t, 4); + }, + easeInQuart: (t: number): number => { + return t * t * t * t; + }, + 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); + }, + easeOutElastic: (t: number): number => { + const c4 = (2 * Math.PI) / 3; + return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + }, +}; + +// ============================================================================ +// Types +// ============================================================================ + +export interface Participant { + id: string; + name: string; + department: string; + zodiac?: string; + age?: number; + avatar?: string; + isEligible?: boolean; +} + +export type LotteryPhase = 'idle' | 'galaxy' | 'storm' | 'reveal' | 'complete'; + +interface NameParticle { + id: string; + name: string; + x: number; + y: number; + z: number; // For 3D sphere effect + targetX: number; + targetY: number; + vx: number; + vy: number; + vz: number; + rotation: number; + rotationSpeed: number; + scale: number; + alpha: number; + isEligible: boolean; + isWinner: boolean; + text: Text | null; +} + +interface ConfettiParticle { + x: number; + y: number; + vx: number; + vy: number; + rotation: number; + rotationSpeed: number; + width: number; + height: number; + color: number; + alpha: number; + gravity: number; +} + +// ============================================================================ +// LotteryMachine Class +// ============================================================================ + +export class LotteryMachine { + private app: Application; + private mainContainer: Container; + private galaxyContainer: Container; + private stormContainer: Container; + private revealContainer: Container; + private confettiContainer: Container; + private scrollContainer: Container; + private dimOverlay: Graphics; + + private nameParticles: NameParticle[] = []; + private confettiParticles: ConfettiParticle[] = []; + private participants: Participant[] = []; + private winners: Participant[] = []; + + private phase: LotteryPhase = 'idle'; + private phaseTime = 0; + private time = 0; + private isDestroyed = false; + + // Galaxy parameters + private sphereRadius = 300; + private sphereRotationX = 0; + private sphereRotationY = 0; + private sphereRotationZ = 0; + + // Storm parameters + private stormIntensity = 0; + private stormAngle = 0; + private blurFilter: BlurFilter; + + // Reveal parameters + private revealProgress = 0; + private scrollScale = 0; + + // Text style + private nameStyle: TextStyle; + private winnerStyle: TextStyle; + + // Callbacks + private onPhaseChange?: (phase: LotteryPhase) => void; + private onWinnersRevealed?: (winners: Participant[]) => void; + + constructor() { + this.app = new Application(); + this.mainContainer = new Container(); + this.galaxyContainer = new Container(); + this.stormContainer = new Container(); + this.revealContainer = new Container(); + this.confettiContainer = new Container(); + this.scrollContainer = new Container(); + this.dimOverlay = new Graphics(); + this.blurFilter = new BlurFilter({ strength: 0 }); + + this.nameStyle = new TextStyle({ + fontFamily: '"Noto Serif SC", serif', + fontSize: 18, + fill: COLORS.gold, + fontWeight: 'bold', + }); + + this.winnerStyle = new TextStyle({ + fontFamily: '"Noto Serif SC", serif', + fontSize: 48, + fill: COLORS.goldLight, + fontWeight: 'bold', + dropShadow: { + color: COLORS.gold, + blur: 10, + distance: 0, + }, + }); + } + + async init(canvas: HTMLCanvasElement): Promise { + await this.app.init({ + canvas, + width: window.innerWidth, + height: window.innerHeight, + backgroundColor: COLORS.wineRed, + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + }); + + // Setup layer hierarchy + this.mainContainer.addChild(this.galaxyContainer); + this.mainContainer.addChild(this.stormContainer); + this.mainContainer.addChild(this.dimOverlay); + this.mainContainer.addChild(this.scrollContainer); + this.mainContainer.addChild(this.revealContainer); + this.mainContainer.addChild(this.confettiContainer); + this.app.stage.addChild(this.mainContainer); + + // Apply blur filter to storm container + this.stormContainer.filters = [this.blurFilter]; + + // Start animation loop + this.app.ticker.add(this.update.bind(this)); + + // Handle resize + window.addEventListener('resize', this.handleResize.bind(this)); + } + + // ============================================================================ + // Public API + // ============================================================================ + + /** + * Set participants for the lottery + */ + setParticipants(participants: Participant[]): void { + this.participants = participants; + this.createNameParticles(); + } + + /** + * Filter participants by criteria (e.g., zodiac for Round 4) + */ + filterParticipants(filter: { zodiac?: string; ageRange?: { min: number; max: number } }): void { + this.nameParticles.forEach((p) => { + const participant = this.participants.find((part) => part.id === p.id); + if (!participant) return; + + let eligible = true; + + if (filter.zodiac && participant.zodiac !== filter.zodiac) { + eligible = false; + } + + if (filter.ageRange) { + const age = participant.age || 0; + if (age < filter.ageRange.min || age > filter.ageRange.max) { + eligible = false; + } + } + + p.isEligible = eligible; + }); + } + + /** + * Start the lottery - transition to Galaxy phase + */ + startGalaxy(): void { + this.phase = 'galaxy'; + this.phaseTime = 0; + this.onPhaseChange?.('galaxy'); + } + + /** + * Start the storm - rolling phase + */ + startStorm(): void { + this.phase = 'storm'; + this.phaseTime = 0; + this.stormIntensity = 0; + this.onPhaseChange?.('storm'); + } + + /** + * Stop and reveal winners + */ + revealWinners(winnerIds: string[]): void { + this.winners = this.participants.filter((p) => winnerIds.includes(p.id)); + + // Mark winner particles + this.nameParticles.forEach((p) => { + p.isWinner = winnerIds.includes(p.id); + }); + + this.phase = 'reveal'; + this.phaseTime = 0; + this.revealProgress = 0; + this.scrollScale = 0; + this.onPhaseChange?.('reveal'); + } + + /** + * Reset to idle state + */ + reset(): void { + this.phase = 'idle'; + this.phaseTime = 0; + this.winners = []; + this.confettiParticles = []; + this.stormIntensity = 0; + this.blurFilter.strength = 0; + + // Reset particles + this.nameParticles.forEach((p) => { + p.isWinner = false; + }); + + // Clear containers + this.revealContainer.removeChildren(); + this.scrollContainer.removeChildren(); + this.confettiContainer.removeChildren(); + this.dimOverlay.clear(); + + this.onPhaseChange?.('idle'); + } + + /** + * Set callbacks + */ + onPhase(callback: (phase: LotteryPhase) => void): void { + this.onPhaseChange = callback; + } + + onWinners(callback: (winners: Participant[]) => void): void { + this.onWinnersRevealed = callback; + } + + // ============================================================================ + // Particle Creation + // ============================================================================ + + private createNameParticles(): void { + // Clear existing + this.galaxyContainer.removeChildren(); + this.stormContainer.removeChildren(); + this.nameParticles = []; + + const centerX = this.app.screen.width / 2; + const centerY = this.app.screen.height / 2; + + this.participants.forEach((participant, index) => { + // Distribute on sphere using fibonacci spiral + const phi = Math.acos(1 - 2 * (index + 0.5) / this.participants.length); + const theta = Math.PI * (1 + Math.sqrt(5)) * index; + + const x = this.sphereRadius * Math.sin(phi) * Math.cos(theta); + const y = this.sphereRadius * Math.sin(phi) * Math.sin(theta); + const z = this.sphereRadius * Math.cos(phi); + + // Create text + const text = new Text({ + text: participant.name, + style: this.nameStyle, + }); + text.anchor.set(0.5); + this.galaxyContainer.addChild(text); + + const particle: NameParticle = { + id: participant.id, + name: participant.name, + x: centerX + x, + y: centerY + y, + z, + targetX: centerX, + targetY: centerY, + vx: 0, + vy: 0, + vz: 0, + rotation: 0, + rotationSpeed: (Math.random() - 0.5) * 0.1, + scale: 1, + alpha: 1, + isEligible: participant.isEligible !== false, + isWinner: false, + text, + }; + + this.nameParticles.push(particle); + }); + } + + // ============================================================================ + // Animation Loop + // ============================================================================ + + private update(ticker: Ticker): void { + if (this.isDestroyed) return; + + this.time += ticker.deltaMS; + this.phaseTime += ticker.deltaMS; + + switch (this.phase) { + case 'galaxy': + this.updateGalaxy(ticker.deltaMS); + break; + case 'storm': + this.updateStorm(ticker.deltaMS); + break; + case 'reveal': + this.updateReveal(ticker.deltaMS); + break; + case 'complete': + this.updateComplete(ticker.deltaMS); + break; + } + + // Always update confetti + this.updateConfetti(ticker.deltaMS); + } + + // ============================================================================ + // Galaxy Phase (Idle Sphere) + // ============================================================================ + + private updateGalaxy(deltaMs: number): void { + const centerX = this.app.screen.width / 2; + const centerY = this.app.screen.height / 2; + + // Slowly rotate the sphere + this.sphereRotationY += deltaMs * 0.0003; + this.sphereRotationX = Math.sin(this.time * 0.0001) * 0.2; + + this.nameParticles.forEach((p) => { + if (!p.text) return; + + // Apply 3D rotation + const cosY = Math.cos(this.sphereRotationY); + const sinY = Math.sin(this.sphereRotationY); + const cosX = Math.cos(this.sphereRotationX); + const sinX = Math.sin(this.sphereRotationX); + + // Rotate around Y axis + const x1 = (p.x - centerX) * cosY - p.z * sinY; + const z1 = (p.x - centerX) * sinY + p.z * cosY; + + // Rotate around X axis + const y1 = (p.y - centerY) * cosX - z1 * sinX; + const z2 = (p.y - centerY) * sinX + z1 * cosX; + + // Project to 2D with perspective + const perspective = 800; + const scale = perspective / (perspective + z2); + + p.text.x = centerX + x1 * scale; + p.text.y = centerY + y1 * scale; + p.text.scale.set(scale * 0.8); + + // Depth-based alpha and color + const depthAlpha = (z2 + this.sphereRadius) / (this.sphereRadius * 2); + p.text.alpha = p.isEligible ? 0.3 + depthAlpha * 0.7 : 0.15; + + // Dim ineligible names + if (!p.isEligible) { + p.text.style.fill = 0x666666; + } else { + p.text.style.fill = COLORS.gold; + } + + // Z-sorting + p.text.zIndex = Math.floor(z2); + }); + + this.galaxyContainer.sortChildren(); + } + + // ============================================================================ + // Storm Phase (Tornado) + // ============================================================================ + + private updateStorm(deltaMs: number): void { + const centerX = this.app.screen.width / 2; + const centerY = this.app.screen.height / 2; + + // Ramp up storm intensity + this.stormIntensity = Math.min(1, this.stormIntensity + deltaMs * 0.001); + this.stormAngle += deltaMs * 0.01 * this.stormIntensity; + + // Apply motion blur based on intensity + this.blurFilter.strength = this.stormIntensity * 8; + + this.nameParticles.forEach((p, index) => { + if (!p.text) return; + + // Tornado vortex motion + const baseAngle = this.stormAngle + (index / this.nameParticles.length) * Math.PI * 2; + const verticalPos = ((this.time * 0.001 + index * 0.1) % 2) - 1; // -1 to 1 + const radius = 100 + Math.abs(verticalPos) * 200 * this.stormIntensity; + + const targetX = centerX + Math.cos(baseAngle) * radius; + const targetY = centerY + verticalPos * 300; + + // Smooth interpolation + p.x += (targetX - p.x) * 0.1; + p.y += (targetY - p.y) * 0.1; + + p.text.x = p.x; + p.text.y = p.y; + p.text.rotation += p.rotationSpeed * this.stormIntensity * 3; + p.text.alpha = p.isEligible ? 0.8 : 0.2; + + // Scale based on position + const distFromCenter = Math.sqrt( + Math.pow(p.x - centerX, 2) + Math.pow(p.y - centerY, 2) + ); + p.text.scale.set(0.5 + (1 - distFromCenter / 400) * 0.5); + }); + } + + // ============================================================================ + // Reveal Phase + // ============================================================================ + + private updateReveal(deltaMs: number): void { + const centerX = this.app.screen.width / 2; + const centerY = this.app.screen.height / 2; + + this.revealProgress = Math.min(1, this.revealProgress + deltaMs * 0.0008); + const eased = Easing.easeOutQuart(this.revealProgress); + + // Reduce blur + this.blurFilter.strength = (1 - eased) * 8; + + // Dim background + this.dimOverlay.clear(); + this.dimOverlay.rect(0, 0, this.app.screen.width, this.app.screen.height); + this.dimOverlay.fill({ color: COLORS.black, alpha: eased * 0.7 }); + + // Disperse non-winners outward + this.nameParticles.forEach((p) => { + if (!p.text) return; + + if (p.isWinner) { + // Winners fly to center + const targetX = centerX; + const targetY = centerY - 50; + + p.x += (targetX - p.x) * 0.05 * eased; + p.y += (targetY - p.y) * 0.05 * eased; + p.text.alpha = 1; + p.text.scale.set(1 + eased * 1.5); + } else { + // Non-winners disperse + const angle = Math.atan2(p.y - centerY, p.x - centerX); + const disperseDistance = 500 * eased; + + p.x += Math.cos(angle) * disperseDistance * 0.02; + p.y += Math.sin(angle) * disperseDistance * 0.02; + p.text.alpha = Math.max(0, 1 - eased * 1.5); + } + + p.text.x = p.x; + p.text.y = p.y; + }); + + // Draw scroll background + if (this.revealProgress > 0.3) { + this.scrollScale = Easing.easeOutBack((this.revealProgress - 0.3) / 0.7); + this.drawScroll(centerX, centerY, this.scrollScale); + } + + // Spawn confetti when reveal is complete + if (this.revealProgress >= 1 && this.phase === 'reveal') { + this.phase = 'complete'; + this.spawnConfettiBurst(); + this.createWinnerDisplay(); + this.onWinnersRevealed?.(this.winners); + this.onPhaseChange?.('complete'); + } + } + + private drawScroll(centerX: number, centerY: number, scale: number): void { + this.scrollContainer.removeChildren(); + + const scrollGraphics = new Graphics(); + const width = 600 * scale; + const height = 400 * scale; + + // Main scroll body + scrollGraphics.roundRect( + centerX - width / 2, + centerY - height / 2, + width, + height, + 20 * scale + ); + scrollGraphics.fill({ color: COLORS.redDark, alpha: 0.95 }); + + // Gold border + scrollGraphics.roundRect( + centerX - width / 2 + 5, + centerY - height / 2 + 5, + width - 10, + height - 10, + 15 * scale + ); + scrollGraphics.stroke({ color: COLORS.gold, width: 3 * scale }); + + // Inner decorative border + scrollGraphics.roundRect( + centerX - width / 2 + 15, + centerY - height / 2 + 15, + width - 30, + height - 30, + 10 * scale + ); + scrollGraphics.stroke({ color: COLORS.goldDark, width: 1 * scale }); + + this.scrollContainer.addChild(scrollGraphics); + } + + private createWinnerDisplay(): void { + this.revealContainer.removeChildren(); + + const centerX = this.app.screen.width / 2; + const centerY = this.app.screen.height / 2; + + // Arrange winners + const winnerCount = this.winners.length; + const spacing = Math.min(120, 500 / winnerCount); + + this.winners.forEach((winner, index) => { + const offsetX = (index - (winnerCount - 1) / 2) * spacing; + + const text = new Text({ + text: winner.name, + style: this.winnerStyle, + }); + text.anchor.set(0.5); + text.x = centerX + offsetX; + text.y = centerY - 30; + + // Department subtitle + const deptText = new Text({ + text: winner.department, + style: new TextStyle({ + fontFamily: '"Noto Serif SC", serif', + fontSize: 20, + fill: COLORS.goldDark, + }), + }); + deptText.anchor.set(0.5); + deptText.x = centerX + offsetX; + deptText.y = centerY + 30; + + this.revealContainer.addChild(text); + this.revealContainer.addChild(deptText); + }); + } + + // ============================================================================ + // Complete Phase + // ============================================================================ + + private updateComplete(deltaMs: number): void { + // Gentle pulsing of winner names + const pulse = Math.sin(this.time * 0.003) * 0.05 + 1; + + this.revealContainer.children.forEach((child, index) => { + if (child instanceof Text && index % 2 === 0) { + child.scale.set(pulse); + } + }); + } + + // ============================================================================ + // Confetti System + // ============================================================================ + + private spawnConfettiBurst(): void { + const centerX = this.app.screen.width / 2; + const colors = [COLORS.gold, COLORS.goldLight, COLORS.red, COLORS.redLight]; + + for (let i = 0; i < 150; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 5 + Math.random() * 10; + + this.confettiParticles.push({ + x: centerX + (Math.random() - 0.5) * 200, + y: this.app.screen.height * 0.3, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 5, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.3, + width: 8 + Math.random() * 8, + height: 4 + Math.random() * 4, + color: colors[Math.floor(Math.random() * colors.length)], + alpha: 1, + gravity: 0.15 + Math.random() * 0.1, + }); + } + } + + private updateConfetti(deltaMs: number): void { + const confettiGraphics = new Graphics(); + this.confettiContainer.removeChildren(); + + this.confettiParticles = this.confettiParticles.filter((p) => { + // Physics + p.vy += p.gravity; + p.vx *= 0.99; // Air resistance + p.x += p.vx; + p.y += p.vy; + p.rotation += p.rotationSpeed; + + // Fade out when below screen + if (p.y > this.app.screen.height) { + p.alpha -= 0.02; + } + + if (p.alpha <= 0) return false; + + // Draw confetti piece (calculate rotated rectangle corners) + const cos = Math.cos(p.rotation); + const sin = Math.sin(p.rotation); + const hw = p.width / 2; + const hh = p.height / 2; + + // Calculate rotated corners + const corners = [ + { x: -hw, y: -hh }, + { x: hw, y: -hh }, + { x: hw, y: hh }, + { x: -hw, y: hh }, + ].map(corner => ({ + x: p.x + corner.x * cos - corner.y * sin, + y: p.y + corner.x * sin + corner.y * cos, + })); + + // Draw as polygon + confettiGraphics.moveTo(corners[0].x, corners[0].y); + confettiGraphics.lineTo(corners[1].x, corners[1].y); + confettiGraphics.lineTo(corners[2].x, corners[2].y); + confettiGraphics.lineTo(corners[3].x, corners[3].y); + confettiGraphics.closePath(); + confettiGraphics.fill({ color: p.color, alpha: p.alpha }); + + return true; + }); + + this.confettiContainer.addChild(confettiGraphics); + } + + // ============================================================================ + // 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.nameParticles = []; + this.confettiParticles = []; + this.app.destroy(true, { children: true }); + } + + // ============================================================================ + // Getters + // ============================================================================ + + get currentPhase(): LotteryPhase { + return this.phase; + } + + get currentWinners(): Participant[] { + return this.winners; + } +} diff --git a/packages/client-screen/src/pixi/VoteParticleSystem.ts b/packages/client-screen/src/pixi/VoteParticleSystem.ts new file mode 100644 index 0000000..46067dd --- /dev/null +++ b/packages/client-screen/src/pixi/VoteParticleSystem.ts @@ -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 = new Map(); + private programTargets: Map = 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 { + 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 }); + } +} diff --git a/packages/client-screen/src/router/index.ts b/packages/client-screen/src/router/index.ts index 5dd0656..e23b22a 100644 --- a/packages/client-screen/src/router/index.ts +++ b/packages/client-screen/src/router/index.ts @@ -1,29 +1,96 @@ -import { createRouter, createWebHistory } from 'vue-router'; +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'; + +// Admin auth constants +const ADMIN_TOKEN_KEY = 'gala_admin_token'; +const ADMIN_ACCESS_CODE = '20268888'; + +// Auth guard for admin routes +function requireAdminAuth(to: RouteLocationNormalized) { + const token = localStorage.getItem(ADMIN_TOKEN_KEY); + if (!token || token !== generateToken(ADMIN_ACCESS_CODE)) { + return { path: '/admin/login', query: { redirect: to.fullPath } }; + } + return true; +} + +// Simple token generator (not cryptographically secure, but sufficient for internal event) +function generateToken(code: string): string { + return btoa(`gala2026:${code}:${code.split('').reverse().join('')}`); +} + +// Export for use in login component +export { ADMIN_TOKEN_KEY, ADMIN_ACCESS_CODE, generateToken }; const router = createRouter({ history: createWebHistory(), routes: [ + // ============================================ + // Big Screen Display Routes (LED PC) + // ============================================ { path: '/', - name: 'main', + name: 'screen-main', component: () => import('../views/MainDisplay.vue'), + meta: { title: '年会大屏 - 主页' }, }, { - path: '/draw', - name: 'draw', + path: '/screen/voting', + name: 'screen-voting', + component: () => import('../views/LiveVotingView.vue'), + meta: { title: '年会大屏 - 实时投票' }, + }, + { + path: '/screen/draw', + name: 'screen-draw', component: () => import('../views/LuckyDrawView.vue'), + meta: { title: '年会大屏 - 幸运抽奖' }, }, { - path: '/results', - name: 'results', + path: '/screen/results', + name: 'screen-results', component: () => import('../views/VoteResultsView.vue'), + meta: { title: '年会大屏 - 投票结果' }, + }, + + // Legacy routes (redirect to new paths) + { path: '/voting', redirect: '/screen/voting' }, + { path: '/draw', redirect: '/screen/draw' }, + { path: '/results', redirect: '/screen/results' }, + + // ============================================ + // Admin Routes (Director Console) + // ============================================ + { + path: '/admin/login', + name: 'admin-login', + component: () => import('../views/AdminLogin.vue'), + meta: { title: '管理员登录' }, }, { - path: '/admin', - name: 'admin', + path: '/admin/director-console', + name: 'admin-console', component: () => import('../views/AdminControl.vue'), + meta: { title: '导演控制台' }, + beforeEnter: requireAdminAuth, + }, + + // Legacy admin route (redirect) + { path: '/admin', redirect: '/admin/director-console' }, + + // ============================================ + // 404 Catch-all + // ============================================ + { + path: '/:pathMatch(.*)*', + name: 'not-found', + redirect: '/', }, ], }); +// Update document title on navigation +router.afterEach((to) => { + document.title = (to.meta.title as string) || '年会互动系统'; +}); + export default router; diff --git a/packages/client-screen/src/stores/admin.ts b/packages/client-screen/src/stores/admin.ts new file mode 100644 index 0000000..8fd46f2 --- /dev/null +++ b/packages/client-screen/src/stores/admin.ts @@ -0,0 +1,391 @@ +import { defineStore } from 'pinia'; +import { ref, computed, shallowRef, watch } from 'vue'; +import { io, Socket } from 'socket.io-client'; +import type { + ServerToClientEvents, + ClientToServerEvents, + AdminState, + SystemPhase, + VotingControlPayload, + LotteryControlPayload, + EmergencyResetPayload, + MusicControlPayload, + LotteryRound, +} from '@gala/shared/types'; +import { PRIZE_CONFIG } from '@gala/shared/types'; +import { SOCKET_EVENTS } from '@gala/shared/constants'; + +type GalaSocket = Socket; + +// Storage key for state persistence +const STORAGE_KEY = 'gala_admin_state'; + +export const useAdminStore = defineStore('admin', () => { + // Socket + const socket = shallowRef(null); + const isConnected = ref(false); + const isConnecting = ref(false); + const latency = ref(0); + const onlineUsers = ref(0); + + // Admin State (mirrors server state) + const systemPhase = ref('IDLE'); + const votingOpen = ref(false); + const votingPaused = ref(false); + const totalVotes = ref(0); + + // Lottery State + const lotteryRound = ref(1); + const lotterySubPhase = ref<'IDLE' | 'GALAXY' | 'STORM' | 'REVEAL' | 'COMPLETE'>('IDLE'); + const stormStartedAt = ref(null); + const currentWinners = ref>([]); + + // Music State + const musicPlaying = ref(false); + const musicTrack = ref<'bgm' | 'lottery' | 'fanfare' | 'none'>('none'); + + // UI State + const pendingAction = ref(null); + const lastError = ref(null); + const lastActionTime = ref(0); + + // Computed + const connectionStatus = computed(() => { + if (isConnected.value) return 'connected'; + if (isConnecting.value) return 'connecting'; + return 'disconnected'; + }); + + const currentPrize = computed(() => { + const config = PRIZE_CONFIG.find(p => p.round === lotteryRound.value); + return config || PRIZE_CONFIG[0]; + }); + + const canStopLottery = computed(() => { + if (lotterySubPhase.value !== 'STORM') return false; + if (!stormStartedAt.value) return false; + // 3-second safety delay + return Date.now() - stormStartedAt.value >= 3000; + }); + + const stopButtonCountdown = computed(() => { + if (lotterySubPhase.value !== 'STORM') return 0; + if (!stormStartedAt.value) return 3; + const elapsed = Date.now() - stormStartedAt.value; + return Math.max(0, Math.ceil((3000 - elapsed) / 1000)); + }); + + // Persist state to localStorage + function saveState() { + const state = { + systemPhase: systemPhase.value, + votingOpen: votingOpen.value, + votingPaused: votingPaused.value, + lotteryRound: lotteryRound.value, + lotterySubPhase: lotterySubPhase.value, + currentWinners: currentWinners.value, + musicPlaying: musicPlaying.value, + musicTrack: musicTrack.value, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + + // Restore state from localStorage + function restoreState() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const state = JSON.parse(saved); + systemPhase.value = state.systemPhase || 'IDLE'; + votingOpen.value = state.votingOpen || false; + votingPaused.value = state.votingPaused || false; + lotteryRound.value = state.lotteryRound || 1; + lotterySubPhase.value = state.lotterySubPhase || 'IDLE'; + currentWinners.value = state.currentWinners || []; + musicPlaying.value = state.musicPlaying || false; + musicTrack.value = state.musicTrack || 'none'; + } + } catch (e) { + console.error('[Admin] Failed to restore state:', e); + } + } + + // Watch for state changes and persist + watch( + [systemPhase, votingOpen, votingPaused, lotteryRound, lotterySubPhase, currentWinners, musicPlaying, musicTrack], + () => saveState(), + { deep: true } + ); + + // Connect to server + function connect() { + if (socket.value?.connected || isConnecting.value) return; + + isConnecting.value = true; + restoreState(); + + const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', { + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 10000, + transports: ['websocket', 'polling'], + }); + + socketInstance.on('connect', () => { + console.log('[Admin] Connected'); + isConnected.value = true; + isConnecting.value = false; + + // Join as admin + socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, { + userId: 'admin_main', + userName: 'Admin Console', + role: 'admin', + }, () => {}); + + // Request state sync + socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, {}); + }); + + socketInstance.on('disconnect', (reason) => { + console.log('[Admin] Disconnected:', reason); + isConnected.value = false; + }); + + socketInstance.on('connect_error', (error) => { + console.error('[Admin] Connection error:', error); + isConnecting.value = false; + }); + + // User count updates + socketInstance.on('connection:users_count' as any, (count: number) => { + onlineUsers.value = count; + }); + + // Admin state sync from server + socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => { + syncFromServer(state); + }); + + socket.value = socketInstance as GalaSocket; + } + + function syncFromServer(state: AdminState) { + systemPhase.value = state.systemPhase; + votingOpen.value = state.voting.subPhase === 'OPEN'; + votingPaused.value = state.voting.subPhase === 'PAUSED'; + totalVotes.value = state.voting.totalVotes; + lotteryRound.value = state.lottery.round; + lotterySubPhase.value = state.lottery.subPhase; + currentWinners.value = state.lottery.currentWinners; + musicPlaying.value = state.music.isPlaying; + musicTrack.value = state.music.track; + saveState(); + } + + function disconnect() { + if (socket.value) { + socket.value.disconnect(); + socket.value = null; + } + isConnected.value = false; + isConnecting.value = false; + } + + // Admin Actions + function setPhase(phase: SystemPhase) { + if (!socket.value?.connected) return; + pendingAction.value = 'phase_change'; + + socket.value.emit(SOCKET_EVENTS.ADMIN_PHASE_CHANGE as any, { + phase, + timestamp: Date.now(), + }, (response: any) => { + pendingAction.value = null; + if (response.success) { + systemPhase.value = phase; + lastActionTime.value = Date.now(); + } else { + lastError.value = response.message || 'Failed to change phase'; + } + }); + } + + function controlVoting(action: 'open' | 'close' | 'pause' | 'resume') { + if (!socket.value?.connected) return; + pendingAction.value = `voting_${action}`; + + socket.value.emit(SOCKET_EVENTS.ADMIN_VOTING_CONTROL as any, { + action, + timestamp: Date.now(), + } as VotingControlPayload, (response: any) => { + pendingAction.value = null; + if (response.success) { + if (action === 'open') { + votingOpen.value = true; + votingPaused.value = false; + } else if (action === 'close') { + votingOpen.value = false; + votingPaused.value = false; + } else if (action === 'pause') { + votingPaused.value = true; + } else if (action === 'resume') { + votingPaused.value = false; + } + lastActionTime.value = Date.now(); + } else { + lastError.value = response.message || `Failed to ${action} voting`; + } + }); + } + + function controlLottery(action: LotteryControlPayload['action'], winnerIds?: string[]) { + if (!socket.value?.connected) return; + pendingAction.value = `lottery_${action}`; + + const payload: LotteryControlPayload = { + action, + round: lotteryRound.value, + timestamp: Date.now(), + }; + if (winnerIds) payload.winnerIds = winnerIds; + + socket.value.emit(SOCKET_EVENTS.ADMIN_LOTTERY_CONTROL as any, payload, (response: any) => { + pendingAction.value = null; + if (response.success) { + if (action === 'start_galaxy') { + lotterySubPhase.value = 'GALAXY'; + } else if (action === 'start_storm') { + lotterySubPhase.value = 'STORM'; + stormStartedAt.value = Date.now(); + } else if (action === 'stop_reveal') { + lotterySubPhase.value = 'REVEAL'; + if (response.data?.winners) { + currentWinners.value = response.data.winners; + } + } else if (action === 'next_round') { + lotteryRound.value = Math.min(4, lotteryRound.value + 1) as LotteryRound; + lotterySubPhase.value = 'IDLE'; + currentWinners.value = []; + stormStartedAt.value = null; + } else if (action === 'reset') { + lotteryRound.value = 1; + lotterySubPhase.value = 'IDLE'; + currentWinners.value = []; + stormStartedAt.value = null; + } + lastActionTime.value = Date.now(); + } else { + lastError.value = response.message || `Failed to ${action}`; + } + }); + } + + function emergencyReset(scope: 'all' | 'voting' | 'lottery', confirmCode: string) { + if (!socket.value?.connected) return; + if (confirmCode !== 'RESET') { + lastError.value = 'Invalid confirmation code'; + return; + } + pendingAction.value = 'emergency_reset'; + + socket.value.emit(SOCKET_EVENTS.ADMIN_EMERGENCY_RESET as any, { + scope, + confirmCode, + timestamp: Date.now(), + } as EmergencyResetPayload, (response: any) => { + pendingAction.value = null; + if (response.success) { + if (scope === 'all' || scope === 'voting') { + votingOpen.value = false; + votingPaused.value = false; + totalVotes.value = 0; + } + if (scope === 'all' || scope === 'lottery') { + lotteryRound.value = 1; + lotterySubPhase.value = 'IDLE'; + currentWinners.value = []; + stormStartedAt.value = null; + } + if (scope === 'all') { + systemPhase.value = 'IDLE'; + } + lastActionTime.value = Date.now(); + } else { + lastError.value = response.message || 'Emergency reset failed'; + } + }); + } + + function controlMusic(action: 'play' | 'stop' | 'change_track', track?: MusicControlPayload['track']) { + if (!socket.value?.connected) return; + pendingAction.value = `music_${action}`; + + socket.value.emit(SOCKET_EVENTS.ADMIN_MUSIC_CONTROL as any, { + action, + track, + } as MusicControlPayload, (response: any) => { + pendingAction.value = null; + if (response.success) { + if (action === 'play') { + musicPlaying.value = true; + if (track) musicTrack.value = track; + } else if (action === 'stop') { + musicPlaying.value = false; + } else if (action === 'change_track' && track) { + musicTrack.value = track; + } + lastActionTime.value = Date.now(); + } else { + lastError.value = response.message || `Failed to ${action} music`; + } + }); + } + + function clearError() { + lastError.value = null; + } + + return { + // Connection + isConnected, + isConnecting, + connectionStatus, + latency, + onlineUsers, + + // State + systemPhase, + votingOpen, + votingPaused, + totalVotes, + lotteryRound, + lotterySubPhase, + stormStartedAt, + currentWinners, + musicPlaying, + musicTrack, + + // Computed + currentPrize, + canStopLottery, + stopButtonCountdown, + + // UI State + pendingAction, + lastError, + lastActionTime, + + // Actions + connect, + disconnect, + setPhase, + controlVoting, + controlLottery, + emergencyReset, + controlMusic, + clearError, + }; +}); diff --git a/packages/client-screen/src/stores/display.ts b/packages/client-screen/src/stores/display.ts index 2eea6b3..488c12d 100644 --- a/packages/client-screen/src/stores/display.ts +++ b/packages/client-screen/src/stores/display.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia'; -import { ref, computed, shallowRef } from 'vue'; +import { ref, computed, shallowRef, watch } from 'vue'; import { io, Socket } from 'socket.io-client'; import type { ServerToClientEvents, @@ -7,6 +7,8 @@ import type { DrawStartPayload, DrawWinnerPayload, VoteUpdatePayload, + AdminState, + SystemPhase, } from '@gala/shared/types'; import { SOCKET_EVENTS } from '@gala/shared/constants'; @@ -67,7 +69,7 @@ export const useDisplayStore = defineStore('display', () => { userId: 'screen_main', userName: 'Main Display', role: 'screen', - }, () => {}); + }, () => { }); }); socketInstance.on('disconnect', (reason) => { @@ -107,6 +109,28 @@ export const useDisplayStore = defineStore('display', () => { window.dispatchEvent(new CustomEvent('vote:updated', { detail: data })); }); + // Admin state sync - listen for phase changes from director console + socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => { + console.log('[Screen] Admin state sync received:', state.systemPhase); + + // Map SystemPhase to display mode + const phaseToMode: Record = { + 'IDLE': 'idle', + 'VOTING': 'voting', + 'LOTTERY': 'draw', + 'RESULTS': 'results', + }; + + const newMode = phaseToMode[state.systemPhase] || 'idle'; + if (currentMode.value !== newMode) { + currentMode.value = newMode; + // Emit custom event for App.vue to handle route navigation + window.dispatchEvent(new CustomEvent('screen:mode_change', { + detail: { mode: newMode, phase: state.systemPhase } + })); + } + }); + socket.value = socketInstance as GalaSocket; } diff --git a/packages/client-screen/src/views/AdminControl.vue b/packages/client-screen/src/views/AdminControl.vue index 08c2f1f..ee884d2 100644 --- a/packages/client-screen/src/views/AdminControl.vue +++ b/packages/client-screen/src/views/AdminControl.vue @@ -1,121 +1,1007 @@ diff --git a/packages/client-screen/src/views/AdminLogin.vue b/packages/client-screen/src/views/AdminLogin.vue new file mode 100644 index 0000000..dfc15ef --- /dev/null +++ b/packages/client-screen/src/views/AdminLogin.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/packages/client-screen/src/views/LiveVotingView.vue b/packages/client-screen/src/views/LiveVotingView.vue new file mode 100644 index 0000000..e9bdbed --- /dev/null +++ b/packages/client-screen/src/views/LiveVotingView.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/packages/client-screen/src/views/LuckyDrawView.vue b/packages/client-screen/src/views/LuckyDrawView.vue index 20d0fea..c31e5eb 100644 --- a/packages/client-screen/src/views/LuckyDrawView.vue +++ b/packages/client-screen/src/views/LuckyDrawView.vue @@ -1,147 +1,287 @@ diff --git a/packages/client-screen/src/views/MainDisplay.vue b/packages/client-screen/src/views/MainDisplay.vue index 6fa62ff..e2cb80e 100644 --- a/packages/client-screen/src/views/MainDisplay.vue +++ b/packages/client-screen/src/views/MainDisplay.vue @@ -1,248 +1,294 @@ diff --git a/packages/client-screen/src/views/VoteResultsView.vue b/packages/client-screen/src/views/VoteResultsView.vue index e0b25a3..27ab2fd 100644 --- a/packages/client-screen/src/views/VoteResultsView.vue +++ b/packages/client-screen/src/views/VoteResultsView.vue @@ -62,7 +62,7 @@ const categories = [