From 25e24a7cbc0b1803d464e987400d0aad1d3ef8cd Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 18 Nov 2025 15:44:47 -0600 Subject: [PATCH] feat: add Know Your World geography quiz game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new arcade game for testing geography knowledge: Game Features: - 4 phases: Setup, Study, Playing, Results - 3 multiplayer modes: Cooperative, Race, Turn-Based - 2 maps: World countries, USA states - Configurable study mode (0, 30, 60, or 120 seconds) - Return to Setup and New Game options in game menu - Small region labels with arrows for improved visibility Map Rendering: - 8-color deterministic palette with hash-based assignment - Opacity-based states (20-27% unfound, 100% found) - Enhanced label visibility with text shadows - Smart bounding box calculation for small regions - Supports both easy (outlines always visible) and hard (outlines on hover/found) difficulty Game Modes: - Cooperative: All players work together to find all regions - Race: First to click gets the point - Turn-Based: Players take turns finding regions Study Phase: - Optional timed study period before quiz starts - Shows all region labels for memorization - Countdown timer with skip option Dependencies: - Add @svg-maps/world and @svg-maps/usa packages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/package.json | 2 + .../src/app/arcade/know-your-world/page.tsx | 13 + .../arcade-games/know-your-world/Provider.tsx | 312 ++++++++++++ .../arcade-games/know-your-world/Validator.ts | 418 +++++++++++++++ .../components/GameComponent.tsx | 41 ++ .../components/MapRenderer.tsx | 290 +++++++++++ .../components/PlayingPhase.tsx | 223 ++++++++ .../components/ResultsPhase.tsx | 212 ++++++++ .../know-your-world/components/SetupPhase.tsx | 478 ++++++++++++++++++ .../know-your-world/components/StudyPhase.tsx | 212 ++++++++ .../src/arcade-games/know-your-world/index.ts | 68 +++ .../arcade-games/know-your-world/mapColors.ts | 131 +++++ .../src/arcade-games/know-your-world/maps.ts | 242 +++++++++ .../src/arcade-games/know-your-world/types.ts | 158 ++++++ .../web/src/lib/arcade/game-config-helpers.ts | 3 + apps/web/src/lib/arcade/game-configs.ts | 15 + apps/web/src/lib/arcade/game-registry.ts | 2 + apps/web/src/lib/arcade/validators.ts | 3 + pnpm-lock.yaml | 16 + 19 files changed, 2839 insertions(+) create mode 100644 apps/web/src/app/arcade/know-your-world/page.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/Provider.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/Validator.ts create mode 100644 apps/web/src/arcade-games/know-your-world/components/GameComponent.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/ResultsPhase.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/components/StudyPhase.tsx create mode 100644 apps/web/src/arcade-games/know-your-world/index.ts create mode 100644 apps/web/src/arcade-games/know-your-world/mapColors.ts create mode 100644 apps/web/src/arcade-games/know-your-world/maps.ts create mode 100644 apps/web/src/arcade-games/know-your-world/types.ts diff --git a/apps/web/package.json b/apps/web/package.json index 92936f85..e433bb6e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,8 @@ "@soroban/abacus-react": "workspace:*", "@soroban/core": "workspace:*", "@soroban/templates": "workspace:*", + "@svg-maps/usa": "^2.0.0", + "@svg-maps/world": "^2.0.0", "@tanstack/react-form": "^0.19.0", "@tanstack/react-query": "^5.90.2", "@types/jsdom": "^21.1.7", diff --git a/apps/web/src/app/arcade/know-your-world/page.tsx b/apps/web/src/app/arcade/know-your-world/page.tsx new file mode 100644 index 00000000..288e029c --- /dev/null +++ b/apps/web/src/app/arcade/know-your-world/page.tsx @@ -0,0 +1,13 @@ +'use client' + +import { knowYourWorldGame } from '@/arcade-games/know-your-world' + +const { Provider, GameComponent } = knowYourWorldGame + +export default function KnowYourWorldPage() { + return ( + + + + ) +} diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx new file mode 100644 index 00000000..6b50e0b2 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -0,0 +1,312 @@ +'use client' + +import { createContext, useCallback, useContext, useMemo } from 'react' +import { + buildPlayerMetadata, + useArcadeSession, + useGameMode, + useRoomData, + useUpdateGameConfig, + useViewerId, +} from '@/lib/arcade/game-sdk' +import type { KnowYourWorldState, KnowYourWorldMove } from './types' + +interface KnowYourWorldContextValue { + state: KnowYourWorldState + lastError: string | null + clearError: () => void + exitSession: () => void + + // Game actions + startGame: () => void + clickRegion: (regionId: string, regionName: string) => void + nextRound: () => void + endGame: () => void + endStudy: () => void + returnToSetup: () => void + + // Setup actions + setMap: (map: 'world' | 'usa') => void + setMode: (mode: 'cooperative' | 'race' | 'turn-based') => void + setDifficulty: (difficulty: 'easy' | 'hard') => void + setStudyDuration: (duration: 0 | 30 | 60 | 120) => void +} + +const KnowYourWorldContext = createContext(null) + +export function useKnowYourWorld() { + const context = useContext(KnowYourWorldContext) + if (!context) { + throw new Error('useKnowYourWorld must be used within KnowYourWorldProvider') + } + return context +} + +export function KnowYourWorldProvider({ children }: { children: React.ReactNode }) { + const { data: viewerId } = useViewerId() + const { roomData } = useRoomData() + const { activePlayers: activePlayerIds, players } = useGameMode() + const { mutate: updateGameConfig } = useUpdateGameConfig() + + const activePlayers = Array.from(activePlayerIds) + + // Merge saved config from room + const initialState = useMemo(() => { + const gameConfig = (roomData?.gameConfig as any)?.['know-your-world'] + + // Validate studyDuration to ensure it's one of the allowed values + const rawDuration = gameConfig?.studyDuration + const studyDuration: 0 | 30 | 60 | 120 = + rawDuration === 30 || rawDuration === 60 || rawDuration === 120 ? rawDuration : 0 + + return { + gamePhase: 'setup' as const, + selectedMap: (gameConfig?.selectedMap as 'world' | 'usa') || 'world', + gameMode: (gameConfig?.gameMode as 'cooperative' | 'race' | 'turn-based') || 'cooperative', + difficulty: (gameConfig?.difficulty as 'easy' | 'hard') || 'easy', + studyDuration, + studyTimeRemaining: 0, + studyStartTime: 0, + currentPrompt: null, + regionsToFind: [], + regionsFound: [], + currentPlayer: '', + scores: {}, + attempts: {}, + guessHistory: [], + startTime: 0, + activePlayers: [], + playerMetadata: {}, + } + }, [roomData]) + + const { state, sendMove, exitSession, lastError, clearError } = + useArcadeSession({ + userId: viewerId || '', + roomId: roomData?.id, + initialState, + applyMove: (state) => state, // Server handles all state updates + }) + + // Action: Start Game + const startGame = useCallback(() => { + const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined) + + sendMove({ + type: 'START_GAME', + playerId: activePlayers[0] || 'player-1', + userId: viewerId || '', + data: { + activePlayers, + playerMetadata, + selectedMap: state.selectedMap, + gameMode: state.gameMode, + difficulty: state.difficulty, + }, + }) + }, [ + activePlayers, + players, + viewerId, + sendMove, + state.selectedMap, + state.gameMode, + state.difficulty, + ]) + + // Action: Click Region + const clickRegion = useCallback( + (regionId: string, regionName: string) => { + sendMove({ + type: 'CLICK_REGION', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: { regionId, regionName }, + }) + }, + [viewerId, sendMove] + ) + + // Action: Next Round + const nextRound = useCallback(() => { + sendMove({ + type: 'NEXT_ROUND', + playerId: activePlayers[0] || 'player-1', + userId: viewerId || '', + data: {}, + }) + }, [activePlayers, viewerId, sendMove]) + + // Action: End Game + const endGame = useCallback(() => { + sendMove({ + type: 'END_GAME', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: {}, + }) + }, [viewerId, sendMove]) + + // Setup Action: Set Map + const setMap = useCallback( + (selectedMap: 'world' | 'usa') => { + sendMove({ + type: 'SET_MAP', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: { selectedMap }, + }) + + // Persist to database + if (roomData?.id) { + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentConfig = (currentGameConfig['know-your-world'] as Record) || {} + + updateGameConfig({ + roomId: roomData.id, + gameConfig: { + ...currentGameConfig, + 'know-your-world': { + ...currentConfig, + selectedMap, + }, + }, + }) + } + }, + [viewerId, sendMove, roomData, updateGameConfig] + ) + + // Setup Action: Set Mode + const setMode = useCallback( + (gameMode: 'cooperative' | 'race' | 'turn-based') => { + sendMove({ + type: 'SET_MODE', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: { gameMode }, + }) + + // Persist to database + if (roomData?.id) { + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentConfig = (currentGameConfig['know-your-world'] as Record) || {} + + updateGameConfig({ + roomId: roomData.id, + gameConfig: { + ...currentGameConfig, + 'know-your-world': { + ...currentConfig, + gameMode, + }, + }, + }) + } + }, + [viewerId, sendMove, roomData, updateGameConfig] + ) + + // Setup Action: Set Difficulty + const setDifficulty = useCallback( + (difficulty: 'easy' | 'hard') => { + sendMove({ + type: 'SET_DIFFICULTY', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: { difficulty }, + }) + + // Persist to database + if (roomData?.id) { + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentConfig = (currentGameConfig['know-your-world'] as Record) || {} + + updateGameConfig({ + roomId: roomData.id, + gameConfig: { + ...currentGameConfig, + 'know-your-world': { + ...currentConfig, + difficulty, + }, + }, + }) + } + }, + [viewerId, sendMove, roomData, updateGameConfig] + ) + + // Setup Action: Set Study Duration + const setStudyDuration = useCallback( + (studyDuration: 0 | 30 | 60 | 120) => { + sendMove({ + type: 'SET_STUDY_DURATION', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: { studyDuration }, + }) + + // Persist to database + if (roomData?.id) { + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentConfig = (currentGameConfig['know-your-world'] as Record) || {} + + updateGameConfig({ + roomId: roomData.id, + gameConfig: { + ...currentGameConfig, + 'know-your-world': { + ...currentConfig, + studyDuration, + }, + }, + }) + } + }, + [viewerId, sendMove, roomData, updateGameConfig] + ) + + // Action: End Study + const endStudy = useCallback(() => { + sendMove({ + type: 'END_STUDY', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: {}, + }) + }, [viewerId, sendMove]) + + // Action: Return to Setup + const returnToSetup = useCallback(() => { + sendMove({ + type: 'RETURN_TO_SETUP', + playerId: viewerId || 'player-1', + userId: viewerId || '', + data: {}, + }) + }, [viewerId, sendMove]) + + return ( + + {children} + + ) +} diff --git a/apps/web/src/arcade-games/know-your-world/Validator.ts b/apps/web/src/arcade-games/know-your-world/Validator.ts new file mode 100644 index 00000000..d3753aef --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/Validator.ts @@ -0,0 +1,418 @@ +import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk' +import type { + KnowYourWorldConfig, + KnowYourWorldMove, + KnowYourWorldState, + GuessRecord, +} from './types' +import { getMapData } from './maps' + +export class KnowYourWorldValidator + implements GameValidator +{ + validateMove(state: KnowYourWorldState, move: KnowYourWorldMove): ValidationResult { + switch (move.type) { + case 'START_GAME': + return this.validateStartGame(state, move.data) + case 'CLICK_REGION': + return this.validateClickRegion(state, move.playerId, move.data) + case 'NEXT_ROUND': + return this.validateNextRound(state) + case 'END_GAME': + return this.validateEndGame(state) + case 'END_STUDY': + return this.validateEndStudy(state) + case 'RETURN_TO_SETUP': + return this.validateReturnToSetup(state) + case 'SET_MAP': + return this.validateSetMap(state, move.data.selectedMap) + case 'SET_MODE': + return this.validateSetMode(state, move.data.gameMode) + case 'SET_DIFFICULTY': + return this.validateSetDifficulty(state, move.data.difficulty) + case 'SET_STUDY_DURATION': + return this.validateSetStudyDuration(state, move.data.studyDuration) + default: + return { valid: false, error: 'Unknown move type' } + } + } + + private validateStartGame(state: KnowYourWorldState, data: any): ValidationResult { + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only start from setup phase' } + } + + const { activePlayers, playerMetadata, selectedMap, gameMode, difficulty } = data + + console.log('[KnowYourWorld Validator] Starting game with:', { + selectedMap, + gameMode, + difficulty, + studyDuration: state.studyDuration, + activePlayers: activePlayers?.length, + }) + + if (!activePlayers || activePlayers.length === 0) { + return { valid: false, error: 'Need at least 1 player' } + } + + // Get map data and shuffle regions + const mapData = getMapData(selectedMap) + console.log('[KnowYourWorld Validator] Map data loaded:', { + map: mapData.id, + regionsCount: mapData.regions.length, + }) + const regionIds = mapData.regions.map((r) => r.id) + const shuffledRegions = this.shuffleArray([...regionIds]) + console.log('[KnowYourWorld Validator] First region to find:', shuffledRegions[0]) + + // Initialize scores and attempts + const scores: Record = {} + const attempts: Record = {} + for (const playerId of activePlayers) { + scores[playerId] = 0 + attempts[playerId] = 0 + } + + // Check if we should go to study phase or directly to playing + const shouldStudy = state.studyDuration > 0 + + const newState: KnowYourWorldState = { + ...state, + gamePhase: shouldStudy ? 'studying' : 'playing', + activePlayers, + playerMetadata, + selectedMap, + gameMode, + difficulty, + studyTimeRemaining: shouldStudy ? state.studyDuration : 0, + studyStartTime: shouldStudy ? Date.now() : 0, + currentPrompt: shouldStudy ? null : shuffledRegions[0], + regionsToFind: shuffledRegions.slice(shouldStudy ? 0 : 1), + regionsFound: [], + currentPlayer: activePlayers[0], + scores, + attempts, + guessHistory: [], + startTime: Date.now(), + } + + return { valid: true, newState } + } + + private validateClickRegion( + state: KnowYourWorldState, + playerId: string, + data: any + ): ValidationResult { + if (state.gamePhase !== 'playing') { + return { valid: false, error: 'Can only click regions during playing phase' } + } + + if (!state.currentPrompt) { + return { valid: false, error: 'No region to find' } + } + + const { regionId, regionName } = data + + // Turn-based mode: Check if it's this player's turn + if (state.gameMode === 'turn-based' && state.currentPlayer !== playerId) { + return { valid: false, error: 'Not your turn' } + } + + const isCorrect = regionId === state.currentPrompt + const guessRecord: GuessRecord = { + playerId, + regionId, + regionName, + correct: isCorrect, + attempts: 1, + timestamp: Date.now(), + } + + if (isCorrect) { + // Correct guess! + const newScores = { ...state.scores } + const newRegionsFound = [...state.regionsFound, regionId] + const guessHistory = [...state.guessHistory, guessRecord] + + // Award points based on mode + if (state.gameMode === 'cooperative') { + // In cooperative mode, all players share the score + for (const pid of state.activePlayers) { + newScores[pid] = (newScores[pid] || 0) + 10 + } + } else { + // In race and turn-based, only the player who guessed gets points + newScores[playerId] = (newScores[playerId] || 0) + 10 + } + + // Check if all regions found + if (state.regionsToFind.length === 0) { + // Game complete! + const newState: KnowYourWorldState = { + ...state, + gamePhase: 'results', + currentPrompt: null, + regionsFound: newRegionsFound, + scores: newScores, + guessHistory, + endTime: Date.now(), + } + return { valid: true, newState } + } + + // Move to next region + const nextPrompt = state.regionsToFind[0] + const remainingRegions = state.regionsToFind.slice(1) + + // For turn-based mode, rotate to next player + let nextPlayer = state.currentPlayer + if (state.gameMode === 'turn-based') { + const currentIndex = state.activePlayers.indexOf(state.currentPlayer) + const nextIndex = (currentIndex + 1) % state.activePlayers.length + nextPlayer = state.activePlayers[nextIndex] + } + + const newState: KnowYourWorldState = { + ...state, + currentPrompt: nextPrompt, + regionsToFind: remainingRegions, + regionsFound: newRegionsFound, + currentPlayer: nextPlayer, + scores: newScores, + guessHistory, + } + + return { valid: true, newState } + } else { + // Incorrect guess + const newAttempts = { ...state.attempts } + newAttempts[playerId] = (newAttempts[playerId] || 0) + 1 + + const guessHistory = [...state.guessHistory, guessRecord] + + // For turn-based mode, rotate to next player after wrong guess + let nextPlayer = state.currentPlayer + if (state.gameMode === 'turn-based') { + const currentIndex = state.activePlayers.indexOf(state.currentPlayer) + const nextIndex = (currentIndex + 1) % state.activePlayers.length + nextPlayer = state.activePlayers[nextIndex] + } + + const newState: KnowYourWorldState = { + ...state, + attempts: newAttempts, + guessHistory, + currentPlayer: nextPlayer, + } + + return { + valid: true, + newState, + error: `Incorrect! Try again. Looking for: ${state.currentPrompt}`, + } + } + } + + private validateNextRound(state: KnowYourWorldState): ValidationResult { + if (state.gamePhase !== 'results') { + return { valid: false, error: 'Can only start next round from results' } + } + + // Get map data and shuffle regions + const mapData = getMapData(state.selectedMap) + const regionIds = mapData.regions.map((r) => r.id) + const shuffledRegions = this.shuffleArray([...regionIds]) + + // Reset game state but keep players and config + const scores: Record = {} + const attempts: Record = {} + for (const playerId of state.activePlayers) { + scores[playerId] = 0 + attempts[playerId] = 0 + } + + // Check if we should go to study phase or directly to playing + const shouldStudy = state.studyDuration > 0 + + const newState: KnowYourWorldState = { + ...state, + gamePhase: shouldStudy ? 'studying' : 'playing', + studyTimeRemaining: shouldStudy ? state.studyDuration : 0, + studyStartTime: shouldStudy ? Date.now() : 0, + currentPrompt: shouldStudy ? null : shuffledRegions[0], + regionsToFind: shuffledRegions.slice(shouldStudy ? 0 : 1), + regionsFound: [], + currentPlayer: state.activePlayers[0], + scores, + attempts, + guessHistory: [], + startTime: Date.now(), + endTime: undefined, + } + + return { valid: true, newState } + } + + private validateEndGame(state: KnowYourWorldState): ValidationResult { + const newState: KnowYourWorldState = { + ...state, + gamePhase: 'results', + currentPrompt: null, + endTime: Date.now(), + } + + return { valid: true, newState } + } + + private validateSetMap( + state: KnowYourWorldState, + selectedMap: 'world' | 'usa' + ): ValidationResult { + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change map during setup' } + } + + const newState: KnowYourWorldState = { + ...state, + selectedMap, + } + + return { valid: true, newState } + } + + private validateSetMode( + state: KnowYourWorldState, + gameMode: 'cooperative' | 'race' | 'turn-based' + ): ValidationResult { + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change mode during setup' } + } + + const newState: KnowYourWorldState = { + ...state, + gameMode, + } + + return { valid: true, newState } + } + + private validateSetDifficulty( + state: KnowYourWorldState, + difficulty: 'easy' | 'hard' + ): ValidationResult { + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change difficulty during setup' } + } + + const newState: KnowYourWorldState = { + ...state, + difficulty, + } + + return { valid: true, newState } + } + + private validateSetStudyDuration( + state: KnowYourWorldState, + studyDuration: 0 | 30 | 60 | 120 + ): ValidationResult { + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change study duration during setup' } + } + + const newState: KnowYourWorldState = { + ...state, + studyDuration, + } + + return { valid: true, newState } + } + + private validateEndStudy(state: KnowYourWorldState): ValidationResult { + if (state.gamePhase !== 'studying') { + return { valid: false, error: 'Can only end study during studying phase' } + } + + // Transition from studying to playing + // Set the first prompt from the regions to find + const currentPrompt = state.regionsToFind[0] || null + const remainingRegions = state.regionsToFind.slice(1) + + const newState: KnowYourWorldState = { + ...state, + gamePhase: 'playing', + currentPrompt, + regionsToFind: remainingRegions, + studyTimeRemaining: 0, + } + + return { valid: true, newState } + } + + private validateReturnToSetup(state: KnowYourWorldState): ValidationResult { + if (state.gamePhase === 'setup') { + return { valid: false, error: 'Already in setup phase' } + } + + // Return to setup, preserving config settings but resetting game state + const newState: KnowYourWorldState = { + ...state, + gamePhase: 'setup', + currentPrompt: null, + regionsToFind: [], + regionsFound: [], + currentPlayer: '', + scores: {}, + attempts: {}, + guessHistory: [], + startTime: 0, + endTime: undefined, + studyTimeRemaining: 0, + studyStartTime: 0, + } + + return { valid: true, newState } + } + + isGameComplete(state: KnowYourWorldState): boolean { + return state.gamePhase === 'results' + } + + getInitialState(config: unknown): KnowYourWorldState { + const typedConfig = config as KnowYourWorldConfig + + return { + gamePhase: 'setup', + selectedMap: typedConfig?.selectedMap || 'world', + gameMode: typedConfig?.gameMode || 'cooperative', + difficulty: typedConfig?.difficulty || 'easy', + studyDuration: typedConfig?.studyDuration || 0, + studyTimeRemaining: 0, + studyStartTime: 0, + currentPrompt: null, + regionsToFind: [], + regionsFound: [], + currentPlayer: '', + scores: {}, + attempts: {}, + guessHistory: [], + startTime: 0, + activePlayers: [], + playerMetadata: {}, + } + } + + // Helper: Shuffle array (Fisher-Yates) + private shuffleArray(array: T[]): T[] { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled + } +} + +export const knowYourWorldValidator = new KnowYourWorldValidator() diff --git a/apps/web/src/arcade-games/know-your-world/components/GameComponent.tsx b/apps/web/src/arcade-games/know-your-world/components/GameComponent.tsx new file mode 100644 index 00000000..00c395f2 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/GameComponent.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { PageWithNav } from '@/components/PageWithNav' +import { useKnowYourWorld } from '../Provider' +import { SetupPhase } from './SetupPhase' +import { StudyPhase } from './StudyPhase' +import { PlayingPhase } from './PlayingPhase' +import { ResultsPhase } from './ResultsPhase' + +export function GameComponent() { + const router = useRouter() + const { state, exitSession, returnToSetup, endGame } = useKnowYourWorld() + + // Determine current player for turn indicator (if turn-based mode) + const currentPlayerId = + state.gamePhase === 'playing' && state.gameMode === 'turn-based' + ? state.currentPlayer + : undefined + + return ( + { + exitSession() + router.push('/arcade') + }} + onSetup={state.gamePhase !== 'setup' ? returnToSetup : undefined} + onNewGame={state.gamePhase !== 'setup' && state.gamePhase !== 'results' ? endGame : undefined} + > + {state.gamePhase === 'setup' && } + {state.gamePhase === 'studying' && } + {state.gamePhase === 'playing' && } + {state.gamePhase === 'results' && } + + ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx new file mode 100644 index 00000000..ebed86e8 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -0,0 +1,290 @@ +'use client' + +import { useState, useMemo } from 'react' +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import type { MapData, MapRegion } from '../types' +import { + getRegionColor, + getRegionStroke, + getRegionStrokeWidth, + getLabelTextColor, + getLabelTextShadow, +} from '../mapColors' + +interface BoundingBox { + minX: number + maxX: number + minY: number + maxY: number + width: number + height: number + area: number +} + +interface SmallRegionLabel { + regionId: string + regionName: string + regionCenter: [number, number] + labelPosition: [number, number] + isFound: boolean +} + +interface MapRendererProps { + mapData: MapData + regionsFound: string[] + currentPrompt: string | null + difficulty: 'easy' | 'hard' + onRegionClick: (regionId: string, regionName: string) => void +} + +/** + * Calculate bounding box from SVG path string + */ +function calculateBoundingBox(pathString: string): BoundingBox { + const numbers = pathString.match(/-?\d+\.?\d*/g)?.map(Number) || [] + + if (numbers.length === 0) { + return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0, area: 0 } + } + + const xCoords: number[] = [] + const yCoords: number[] = [] + + for (let i = 0; i < numbers.length; i += 2) { + xCoords.push(numbers[i]) + if (i + 1 < numbers.length) { + yCoords.push(numbers[i + 1]) + } + } + + const minX = Math.min(...xCoords) + const maxX = Math.max(...xCoords) + const minY = Math.min(...yCoords) + const maxY = Math.max(...yCoords) + const width = maxX - minX + const height = maxY - minY + const area = width * height + + return { minX, maxX, minY, maxY, width, height, area } +} + +/** + * Determine if a region is too small to click easily + */ +function isSmallRegion(bbox: BoundingBox, viewBox: string): boolean { + // Parse viewBox to get map dimensions + const viewBoxParts = viewBox.split(' ').map(Number) + const mapWidth = viewBoxParts[2] || 1000 + const mapHeight = viewBoxParts[3] || 1000 + + // Thresholds (relative to map size) + const minWidth = mapWidth * 0.025 // 2.5% of map width + const minHeight = mapHeight * 0.025 // 2.5% of map height + const minArea = (mapWidth * mapHeight) * 0.001 // 0.1% of total map area + + return bbox.width < minWidth || bbox.height < minHeight || bbox.area < minArea +} + +export function MapRenderer({ + mapData, + regionsFound, + currentPrompt, + difficulty, + onRegionClick, +}: MapRendererProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const [hoveredRegion, setHoveredRegion] = useState(null) + + // Calculate small regions that need labels with arrows + const smallRegionLabels = useMemo(() => { + const labels: SmallRegionLabel[] = [] + const viewBoxParts = mapData.viewBox.split(' ').map(Number) + const mapWidth = viewBoxParts[2] || 1000 + const mapHeight = viewBoxParts[3] || 1000 + + mapData.regions.forEach((region) => { + const bbox = calculateBoundingBox(region.path) + if (isSmallRegion(bbox, mapData.viewBox)) { + // Position label to the right and slightly down from region + // This is a simple strategy - could be improved with collision detection + const offsetX = mapWidth * 0.08 + const offsetY = mapHeight * 0.03 + + labels.push({ + regionId: region.id, + regionName: region.name, + regionCenter: region.center, + labelPosition: [ + region.center[0] + offsetX, + region.center[1] + offsetY, + ], + isFound: regionsFound.includes(region.id), + }) + } + }) + + return labels + }, [mapData, regionsFound]) + + const showOutline = (region: MapRegion): boolean => { + // Easy mode: always show outlines + if (difficulty === 'easy') return true + + // Hard mode: only show outline on hover or if found + return hoveredRegion === region.id || regionsFound.includes(region.id) + } + + return ( +
+ + {/* Background */} + + + {/* Render all regions */} + {mapData.regions.map((region) => ( + + {/* Region path */} + setHoveredRegion(region.id)} + onMouseLeave={() => setHoveredRegion(null)} + onClick={() => onRegionClick(region.id, region.name)} + style={{ + cursor: 'pointer', + transition: 'all 0.2s ease', + }} + /> + + {/* Region label (show if found) */} + {regionsFound.includes(region.id) && ( + + {region.name} + + )} + + ))} + + {/* Small region labels with arrows */} + {smallRegionLabels.map((label) => ( + + {/* Arrow line from label to region center */} + + + {/* Label background */} + onRegionClick(label.regionId, label.regionName)} + onMouseEnter={() => setHoveredRegion(label.regionId)} + onMouseLeave={() => setHoveredRegion(null)} + /> + + {/* Label text */} + onRegionClick(label.regionId, label.regionName)} + onMouseEnter={() => setHoveredRegion(label.regionId)} + onMouseLeave={() => setHoveredRegion(null)} + > + {label.regionName} + + + ))} + + {/* Arrow marker definition */} + + + + + + + +
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx new file mode 100644 index 00000000..d33c4d59 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useEffect } from 'react' +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import { useKnowYourWorld } from '../Provider' +import { getMapData } from '../maps' +import { MapRenderer } from './MapRenderer' + +export function PlayingPhase() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const { state, clickRegion, lastError, clearError } = useKnowYourWorld() + + const mapData = getMapData(state.selectedMap) + const totalRegions = mapData.regions.length + const foundCount = state.regionsFound.length + const progress = (foundCount / totalRegions) * 100 + + // Auto-dismiss errors after 3 seconds + useEffect(() => { + if (lastError) { + const timeout = setTimeout(() => clearError(), 3000) + return () => clearTimeout(timeout) + } + }, [lastError, clearError]) + + // Get the display name for the current prompt + const currentRegionName = state.currentPrompt + ? mapData.regions.find((r) => r.id === state.currentPrompt)?.name + : null + + return ( +
+ {/* Current Prompt */} +
+
+ Find this location: +
+
+ {currentRegionName || '...'} +
+
+ + {/* Error Display */} + {lastError && ( +
+ ⚠️ +
+
Incorrect!
+
{lastError}
+
+ +
+ )} + + {/* Progress Bar */} +
+
+ + {foundCount} / {totalRegions} + +
+
+ + {/* Map */} + + + {/* Game Mode Info */} +
+
+
+ Map +
+
{mapData.name}
+
+
+
+ Mode +
+
+ {state.gameMode === 'cooperative' && '🤝 Cooperative'} + {state.gameMode === 'race' && '🏁 Race'} + {state.gameMode === 'turn-based' && '↔️ Turn-Based'} +
+
+
+
+ Difficulty +
+
+ {state.difficulty === 'easy' && '😊 Easy'} + {state.difficulty === 'hard' && '🤔 Hard'} +
+
+
+ + {/* Turn Indicator (for turn-based mode) */} + {state.gameMode === 'turn-based' && ( +
+ Current Turn: {state.playerMetadata[state.currentPlayer]?.name || state.currentPlayer} +
+ )} +
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/ResultsPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/ResultsPhase.tsx new file mode 100644 index 00000000..8f021317 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/ResultsPhase.tsx @@ -0,0 +1,212 @@ +'use client' + +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import { useKnowYourWorld } from '../Provider' +import { getMapData } from '../maps' + +export function ResultsPhase() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const { state, nextRound } = useKnowYourWorld() + + const mapData = getMapData(state.selectedMap) + const totalRegions = mapData.regions.length + const elapsedTime = state.endTime ? state.endTime - state.startTime : 0 + const minutes = Math.floor(elapsedTime / 60000) + const seconds = Math.floor((elapsedTime % 60000) / 1000) + + // Sort players by score + const sortedPlayers = state.activePlayers + .map((playerId) => ({ + playerId, + name: state.playerMetadata[playerId]?.name || playerId, + emoji: state.playerMetadata[playerId]?.emoji || '👤', + score: state.scores[playerId] || 0, + attempts: state.attempts[playerId] || 0, + })) + .sort((a, b) => b.score - a.score) + + const winner = sortedPlayers[0] + + return ( +
+ {/* Victory Banner */} +
+
🎉
+
+ {state.gameMode === 'cooperative' ? 'Great Teamwork!' : 'Winner!'} +
+ {state.gameMode !== 'cooperative' && winner && ( +
+ {winner.emoji} {winner.name} +
+ )} +
+ + {/* Stats */} +
+

+ Game Statistics +

+
+
+
+ Regions Found +
+
+ {totalRegions} / {totalRegions} +
+
+
+
+ Time +
+
+ {minutes}:{seconds.toString().padStart(2, '0')} +
+
+
+
+ + {/* Player Scores */} +
+

+ {state.gameMode === 'cooperative' ? 'Team Score' : 'Leaderboard'} +

+
+ {sortedPlayers.map((player, index) => ( +
+
{player.emoji}
+
+
+ {player.name} +
+
+ {player.attempts} wrong clicks +
+
+
+ {player.score} pts +
+
+ ))} +
+
+ + {/* Action Buttons */} +
+ +
+
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx new file mode 100644 index 00000000..fd09e0d7 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/SetupPhase.tsx @@ -0,0 +1,478 @@ +'use client' + +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import { useKnowYourWorld } from '../Provider' + +export function SetupPhase() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const { state, startGame, setMap, setMode, setDifficulty, setStudyDuration } = useKnowYourWorld() + + return ( +
+ {/* Map Selection */} +
+

+ Choose a Map 🗺️ +

+
+ + + +
+
+ + {/* Mode Selection */} +
+

+ Game Mode 🎮 +

+
+ + + + + +
+
+ + {/* Difficulty Selection */} +
+

+ Difficulty ⭐ +

+
+ + + +
+
+ + {/* Study Mode Selection */} +
+

+ Study Mode 📚 +

+
+ + + + + + + +
+
+ + {/* Start Button */} + +
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/StudyPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/StudyPhase.tsx new file mode 100644 index 00000000..da57a971 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/StudyPhase.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useEffect, useState } from 'react' +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import { useKnowYourWorld } from '../Provider' +import { getMapData } from '../maps' +import type { MapRegion } from '../types' +import { getRegionColor, getLabelTextColor, getLabelTextShadow } from '../mapColors' + +export function StudyPhase() { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const { state, endStudy } = useKnowYourWorld() + + const [timeRemaining, setTimeRemaining] = useState(state.studyTimeRemaining) + + const mapData = getMapData(state.selectedMap) + + // Countdown timer + useEffect(() => { + const interval = setInterval(() => { + setTimeRemaining((prev) => { + const newTime = Math.max(0, prev - 1) + + // Auto-transition to playing phase when timer reaches 0 + if (newTime === 0) { + clearInterval(interval) + setTimeout(() => endStudy(), 100) + } + + return newTime + }) + }, 1000) + + return () => clearInterval(interval) + }, [endStudy]) + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + return ( +
+ {/* Header with timer */} +
+
+

+ Study Time 📚 +

+

+ Memorize the locations - the quiz starts when the timer ends! +

+
+ +
+
+ {formatTime(timeRemaining)} +
+ +
+
+ + {/* Map with all labels visible */} +
+ + {/* Background */} + + + {/* Render all regions with labels */} + {mapData.regions.map((region: MapRegion) => ( + + {/* Region path */} + + + {/* Region label - ALWAYS visible during study */} + + {region.name} + + + ))} + +
+ + {/* Study tips */} +
+

💡 Study Tips:

+
    +
  • Look for patterns - neighboring regions, shapes, sizes
  • +
  • Group regions mentally by area (e.g., Northeast, Southwest)
  • +
  • Focus on the tricky small ones that are hard to see
  • +
  • The quiz will be in random order - study them all!
  • +
+
+
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/index.ts b/apps/web/src/arcade-games/know-your-world/index.ts new file mode 100644 index 00000000..f137685e --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/index.ts @@ -0,0 +1,68 @@ +import { defineGame } from '@/lib/arcade/game-sdk' +import type { GameManifest } from '@/lib/arcade/game-sdk' +import { GameComponent } from './components/GameComponent' +import { KnowYourWorldProvider } from './Provider' +import type { KnowYourWorldConfig, KnowYourWorldMove, KnowYourWorldState } from './types' +import { knowYourWorldValidator } from './Validator' + +const manifest: GameManifest = { + name: 'know-your-world', + displayName: 'Know Your World', + icon: '🌍', + description: 'Test your geography knowledge by finding countries and states on the map!', + longDescription: `A geography quiz game where you identify countries and states on unlabeled maps. + +Features three exciting game modes: +• Cooperative - Work together as a team +• Race - Compete to click first +• Turn-Based - Take turns finding locations + +Choose from multiple maps (World, USA States) and difficulty levels!`, + maxPlayers: 8, + difficulty: 'Beginner', + chips: ['👥 Multiplayer', '🎓 Educational', '🗺️ Geography'], + color: 'blue', + gradient: 'linear-gradient(135deg, #3b82f6, #60a5fa)', + borderColor: 'blue.200', + available: true, +} + +const defaultConfig: KnowYourWorldConfig = { + selectedMap: 'world', + gameMode: 'cooperative', + difficulty: 'easy', + studyDuration: 0, +} + +function validateKnowYourWorldConfig(config: unknown): config is KnowYourWorldConfig { + return ( + typeof config === 'object' && + config !== null && + 'selectedMap' in config && + 'gameMode' in config && + 'difficulty' in config && + 'studyDuration' in config && + (config.selectedMap === 'world' || config.selectedMap === 'usa') && + (config.gameMode === 'cooperative' || + config.gameMode === 'race' || + config.gameMode === 'turn-based') && + (config.difficulty === 'easy' || config.difficulty === 'hard') && + (config.studyDuration === 0 || + config.studyDuration === 30 || + config.studyDuration === 60 || + config.studyDuration === 120) + ) +} + +export const knowYourWorldGame = defineGame< + KnowYourWorldConfig, + KnowYourWorldState, + KnowYourWorldMove +>({ + manifest, + Provider: KnowYourWorldProvider, + GameComponent, + validator: knowYourWorldValidator, + defaultConfig, + validateConfig: validateKnowYourWorldConfig, +}) diff --git a/apps/web/src/arcade-games/know-your-world/mapColors.ts b/apps/web/src/arcade-games/know-your-world/mapColors.ts new file mode 100644 index 00000000..df50d6d1 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/mapColors.ts @@ -0,0 +1,131 @@ +/** + * Map coloring utilities for Know Your World game + * Provides distinct colors for map regions to improve visual clarity + */ + +// Color palette: 8 distinct, visually appealing colors +// These colors are chosen to be distinguishable and work well at different opacities +export const REGION_COLOR_PALETTE = [ + { name: 'blue', base: '#3b82f6', light: '#93c5fd', dark: '#1e40af' }, + { name: 'green', base: '#10b981', light: '#6ee7b7', dark: '#047857' }, + { name: 'purple', base: '#8b5cf6', light: '#c4b5fd', dark: '#6d28d9' }, + { name: 'orange', base: '#f97316', light: '#fdba74', dark: '#c2410c' }, + { name: 'pink', base: '#ec4899', light: '#f9a8d4', dark: '#be185d' }, + { name: 'yellow', base: '#eab308', light: '#fde047', dark: '#a16207' }, + { name: 'teal', base: '#14b8a6', light: '#5eead4', dark: '#0f766e' }, + { name: 'red', base: '#ef4444', light: '#fca5a5', dark: '#b91c1c' }, +] as const + +/** + * Hash function to deterministically assign a color to a region based on its ID + * This ensures the same region always gets the same color across sessions + */ +function hashString(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash) +} + +/** + * Get color index for a region ID + */ +export function getRegionColorIndex(regionId: string): number { + return hashString(regionId) % REGION_COLOR_PALETTE.length +} + +/** + * Get color for a region based on its state + */ +export function getRegionColor( + regionId: string, + isFound: boolean, + isHovered: boolean, + isDark: boolean +): string { + const colorIndex = getRegionColorIndex(regionId) + const color = REGION_COLOR_PALETTE[colorIndex] + + if (isFound) { + // Found: use base color with full opacity + return color.base + } else if (isHovered) { + // Hovered: use light color with medium opacity + return isDark + ? `${color.light}66` // 40% opacity in dark mode + : `${color.base}55` // 33% opacity in light mode + } else { + // Not found: use very light color with low opacity + return isDark + ? `${color.light}33` // 20% opacity in dark mode + : `${color.light}44` // 27% opacity in light mode + } +} + +/** + * Get stroke (border) color for a region + */ +export function getRegionStroke(isFound: boolean, isDark: boolean): string { + if (isFound) { + return isDark ? '#ffffff' : '#000000' // High contrast for found regions + } + return isDark ? '#1f2937' : '#ffffff' // Subtle border for unfound regions +} + +/** + * Get stroke width for a region + */ +export function getRegionStrokeWidth(isHovered: boolean, isFound: boolean): number { + if (isHovered) return 3 + if (isFound) return 2 + return 0.5 +} + +/** + * Get text color for label based on background + * Uses high contrast to ensure readability + */ +export function getLabelTextColor(isDark: boolean, isFound: boolean): string { + if (isFound) { + // For found regions with colored backgrounds, use white text + return '#ffffff' + } + // For unfound regions, use standard text color + return isDark ? '#e5e7eb' : '#1f2937' +} + +/** + * Get text shadow for label to ensure visibility + * Creates a strong outline effect + */ +export function getLabelTextShadow(isDark: boolean, isFound: boolean): string { + if (isFound) { + // Strong shadow for found regions (white text on colored background) + return ` + 0 0 3px rgba(0,0,0,0.9), + 0 0 6px rgba(0,0,0,0.7), + 1px 1px 0 rgba(0,0,0,0.8), + -1px -1px 0 rgba(0,0,0,0.8), + 1px -1px 0 rgba(0,0,0,0.8), + -1px 1px 0 rgba(0,0,0,0.8) + ` + } + + // Subtle shadow for unfound regions + return isDark + ? ` + 0 0 3px rgba(0,0,0,0.8), + 0 0 6px rgba(0,0,0,0.5), + 1px 1px 0 rgba(0,0,0,0.6), + -1px -1px 0 rgba(0,0,0,0.6) + ` + : ` + 0 0 3px rgba(255,255,255,0.9), + 0 0 6px rgba(255,255,255,0.7), + 1px 1px 0 rgba(255,255,255,0.8), + -1px -1px 0 rgba(255,255,255,0.8) + ` +} diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts new file mode 100644 index 00000000..94e7785d --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -0,0 +1,242 @@ +// @ts-ignore - ESM/CommonJS compatibility +import World from '@svg-maps/world' +// @ts-ignore - ESM/CommonJS compatibility +import USA from '@svg-maps/usa' +import type { MapData, MapRegion } from './types' + +/** + * Calculate the centroid (center of mass) of an SVG path + * Properly parses SVG path commands to extract endpoint coordinates only + */ +function calculatePathCenter(pathString: string): [number, number] { + const points: Array<[number, number]> = [] + + // Parse SVG path commands to extract endpoint coordinates + // Regex matches: command letter followed by numbers + const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g + let currentX = 0 + let currentY = 0 + let match + + while ((match = commandRegex.exec(pathString)) !== null) { + const command = match[1] + const params = match[2].trim().match(/-?\d+\.?\d*/g)?.map(Number) || [] + + switch (command) { + case 'M': // Move to (absolute) + if (params.length >= 2) { + currentX = params[0] + currentY = params[1] + points.push([currentX, currentY]) + } + break + + case 'm': // Move to (relative) + if (params.length >= 2) { + currentX += params[0] + currentY += params[1] + points.push([currentX, currentY]) + } + break + + case 'L': // Line to (absolute) + for (let i = 0; i < params.length - 1; i += 2) { + currentX = params[i] + currentY = params[i + 1] + points.push([currentX, currentY]) + } + break + + case 'l': // Line to (relative) + for (let i = 0; i < params.length - 1; i += 2) { + currentX += params[i] + currentY += params[i + 1] + points.push([currentX, currentY]) + } + break + + case 'H': // Horizontal line (absolute) + for (const x of params) { + currentX = x + points.push([currentX, currentY]) + } + break + + case 'h': // Horizontal line (relative) + for (const dx of params) { + currentX += dx + points.push([currentX, currentY]) + } + break + + case 'V': // Vertical line (absolute) + for (const y of params) { + currentY = y + points.push([currentX, currentY]) + } + break + + case 'v': // Vertical line (relative) + for (const dy of params) { + currentY += dy + points.push([currentX, currentY]) + } + break + + case 'C': // Cubic Bezier (absolute) - take only the endpoint (last 2 params) + for (let i = 0; i < params.length - 1; i += 6) { + if (i + 5 < params.length) { + currentX = params[i + 4] + currentY = params[i + 5] + points.push([currentX, currentY]) + } + } + break + + case 'c': // Cubic Bezier (relative) - take only the endpoint + for (let i = 0; i < params.length - 1; i += 6) { + if (i + 5 < params.length) { + currentX += params[i + 4] + currentY += params[i + 5] + points.push([currentX, currentY]) + } + } + break + + case 'Q': // Quadratic Bezier (absolute) - take only the endpoint (last 2 params) + for (let i = 0; i < params.length - 1; i += 4) { + if (i + 3 < params.length) { + currentX = params[i + 2] + currentY = params[i + 3] + points.push([currentX, currentY]) + } + } + break + + case 'q': // Quadratic Bezier (relative) - take only the endpoint + for (let i = 0; i < params.length - 1; i += 4) { + if (i + 3 < params.length) { + currentX += params[i + 2] + currentY += params[i + 3] + points.push([currentX, currentY]) + } + } + break + + case 'Z': + case 'z': + // Close path - no new point needed + break + } + } + + if (points.length === 0) { + return [0, 0] + } + + if (points.length < 3) { + // Not enough points for a polygon, fallback to average + const avgX = points.reduce((sum, p) => sum + p[0], 0) / points.length + const avgY = points.reduce((sum, p) => sum + p[1], 0) / points.length + return [avgX, avgY] + } + + // Calculate polygon centroid using shoelace formula + let signedArea = 0 + let cx = 0 + let cy = 0 + + for (let i = 0; i < points.length; i++) { + const [x0, y0] = points[i] + const [x1, y1] = points[(i + 1) % points.length] + + const crossProduct = x0 * y1 - x1 * y0 + signedArea += crossProduct + cx += (x0 + x1) * crossProduct + cy += (y0 + y1) * crossProduct + } + + signedArea *= 0.5 + + // Avoid division by zero + if (Math.abs(signedArea) < 0.0001) { + // Fallback to average of all points + const avgX = points.reduce((sum, p) => sum + p[0], 0) / points.length + const avgY = points.reduce((sum, p) => sum + p[1], 0) / points.length + return [avgX, avgY] + } + + cx = cx / (6 * signedArea) + cy = cy / (6 * signedArea) + + return [cx, cy] +} + +/** + * Convert @svg-maps location data to our MapRegion format + */ +function convertToMapRegions( + locations: Array<{ id: string; name: string; path: string }> +): MapRegion[] { + return locations.map((location) => ({ + id: location.id, + name: location.name, + path: location.path, + center: calculatePathCenter(location.path), + })) +} + +/** + * World map with all countries + * Data from @svg-maps/world package + */ +export const WORLD_MAP: MapData = { + id: 'world', + name: World.label || 'Map of World', + viewBox: World.viewBox, + regions: convertToMapRegions(World.locations || []), +} + +/** + * USA map with all states + * Data from @svg-maps/usa package + */ +export const USA_MAP: MapData = { + id: 'usa', + name: USA.label || 'Map of USA', + viewBox: USA.viewBox, + regions: convertToMapRegions(USA.locations || []), +} + +// Log to help debug if data is missing +if (typeof window === 'undefined') { + // Server-side + console.log('[KnowYourWorld] Server: World regions loaded:', WORLD_MAP.regions.length) + console.log('[KnowYourWorld] Server: USA regions loaded:', USA_MAP.regions.length) +} else { + // Client-side + console.log('[KnowYourWorld] Client: World regions loaded:', WORLD_MAP.regions.length) + console.log('[KnowYourWorld] Client: USA regions loaded:', USA_MAP.regions.length) +} + +/** + * Get map data by ID + */ +export function getMapData(mapId: 'world' | 'usa'): MapData { + switch (mapId) { + case 'world': + return WORLD_MAP + case 'usa': + return USA_MAP + default: + return WORLD_MAP + } +} + +/** + * Get a specific region by ID from a map + */ +export function getRegionById(mapId: 'world' | 'usa', regionId: string) { + const mapData = getMapData(mapId) + return mapData.regions.find((r) => r.id === regionId) +} diff --git a/apps/web/src/arcade-games/know-your-world/types.ts b/apps/web/src/arcade-games/know-your-world/types.ts new file mode 100644 index 00000000..ff04c7c4 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/types.ts @@ -0,0 +1,158 @@ +import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk' + +// Game configuration (persisted to database) +export interface KnowYourWorldConfig extends GameConfig { + selectedMap: 'world' | 'usa' + gameMode: 'cooperative' | 'race' | 'turn-based' + difficulty: 'easy' | 'hard' + studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode) +} + +// Map data structures +export interface MapRegion { + id: string // Unique identifier (e.g., "france", "california") + name: string // Display name (e.g., "France", "California") + path: string // SVG path data for the region boundary + center: [number, number] // [x, y] coordinates for label placement +} + +export interface MapData { + id: string // "world" or "usa" + name: string // "World" or "USA States" + viewBox: string // SVG viewBox attribute (e.g., "0 0 1000 500") + regions: MapRegion[] +} + +// Individual guess record +export interface GuessRecord { + playerId: string + regionId: string + regionName: string + correct: boolean + attempts: number // How many tries before getting it right + timestamp: number +} + +// Game state (synchronized across clients) +export interface KnowYourWorldState extends GameState { + gamePhase: 'setup' | 'studying' | 'playing' | 'results' + + // Setup configuration + selectedMap: 'world' | 'usa' + gameMode: 'cooperative' | 'race' | 'turn-based' + difficulty: 'easy' | 'hard' + studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode) + + // Study phase + studyTimeRemaining: number // seconds remaining in study phase + studyStartTime: number // timestamp when study phase started + + // Game progression + currentPrompt: string | null // Region name to find (e.g., "France") + regionsToFind: string[] // Queue of region IDs still to find + regionsFound: string[] // Region IDs already found + currentPlayer: string // For turn-based mode + + // Scoring + scores: Record // playerId -> points + attempts: Record // playerId -> total wrong clicks + guessHistory: GuessRecord[] // Complete history of all guesses + + // Timing + startTime: number + endTime?: number + + // Multiplayer + activePlayers: string[] + playerMetadata: Record +} + +// Move types +export type KnowYourWorldMove = + | { + type: 'START_GAME' + playerId: string + userId: string + timestamp: number + data: { + activePlayers: string[] + playerMetadata: Record + selectedMap: 'world' | 'usa' + gameMode: 'cooperative' | 'race' | 'turn-based' + difficulty: 'easy' | 'hard' + } + } + | { + type: 'CLICK_REGION' + playerId: string + userId: string + timestamp: number + data: { + regionId: string + regionName: string + } + } + | { + type: 'NEXT_ROUND' + playerId: string + userId: string + timestamp: number + data: {} + } + | { + type: 'END_GAME' + playerId: string + userId: string + timestamp: number + data: {} + } + | { + type: 'SET_MAP' + playerId: string + userId: string + timestamp: number + data: { + selectedMap: 'world' | 'usa' + } + } + | { + type: 'SET_MODE' + playerId: string + userId: string + timestamp: number + data: { + gameMode: 'cooperative' | 'race' | 'turn-based' + } + } + | { + type: 'SET_DIFFICULTY' + playerId: string + userId: string + timestamp: number + data: { + difficulty: 'easy' | 'hard' + } + } + | { + type: 'SET_STUDY_DURATION' + playerId: string + userId: string + timestamp: number + data: { + studyDuration: 0 | 30 | 60 | 120 + } + } + | { + type: 'END_STUDY' + playerId: string + userId: string + timestamp: number + data: {} + } + | { + type: 'RETURN_TO_SETUP' + playerId: string + userId: string + timestamp: number + data: {} + } diff --git a/apps/web/src/lib/arcade/game-config-helpers.ts b/apps/web/src/lib/arcade/game-config-helpers.ts index 2d58ddd3..8bdd8940 100644 --- a/apps/web/src/lib/arcade/game-config-helpers.ts +++ b/apps/web/src/lib/arcade/game-config-helpers.ts @@ -17,6 +17,7 @@ import { DEFAULT_CARD_SORTING_CONFIG, DEFAULT_RITHMOMACHIA_CONFIG, DEFAULT_YIJS_DEMO_CONFIG, + DEFAULT_KNOW_YOUR_WORLD_CONFIG, } from './game-configs' // Lazy-load game registry to avoid loading React components on server @@ -58,6 +59,8 @@ function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[Exte return DEFAULT_RITHMOMACHIA_CONFIG case 'yjs-demo': return DEFAULT_YIJS_DEMO_CONFIG + case 'know-your-world': + return DEFAULT_KNOW_YOUR_WORLD_CONFIG default: throw new Error(`Unknown game: ${gameName}`) } diff --git a/apps/web/src/lib/arcade/game-configs.ts b/apps/web/src/lib/arcade/game-configs.ts index 361dab34..bed60e4e 100644 --- a/apps/web/src/lib/arcade/game-configs.ts +++ b/apps/web/src/lib/arcade/game-configs.ts @@ -18,6 +18,7 @@ import type { matchingGame } from '@/arcade-games/matching' import type { cardSortingGame } from '@/arcade-games/card-sorting' import type { yjsDemoGame } from '@/arcade-games/yjs-demo' import type { rithmomachiaGame } from '@/arcade-games/rithmomachia' +import type { knowYourWorldGame } from '@/arcade-games/know-your-world' /** * Utility type: Extract config type from a game definition @@ -59,6 +60,12 @@ export type YjsDemoGameConfig = InferGameConfig */ export type RithmomachiaGameConfig = InferGameConfig +/** + * Configuration for know-your-world (Geography Quiz) game + * INFERRED from knowYourWorldGame.defaultConfig + */ +export type KnowYourWorldConfig = InferGameConfig + // ============================================================================ // Legacy Games (Manual Type Definitions) // TODO: Migrate these games to the modular system for type inference @@ -120,6 +127,7 @@ export type GameConfigByName = { 'card-sorting': CardSortingGameConfig 'yjs-demo': YjsDemoGameConfig rithmomachia: RithmomachiaGameConfig + 'know-your-world': KnowYourWorldConfig // Legacy games (manual types) 'complement-race': ComplementRaceGameConfig @@ -172,6 +180,13 @@ export const DEFAULT_YIJS_DEMO_CONFIG: YjsDemoGameConfig = { duration: 60, } +export const DEFAULT_KNOW_YOUR_WORLD_CONFIG: KnowYourWorldConfig = { + selectedMap: 'world', + gameMode: 'cooperative', + difficulty: 'easy', + studyDuration: 0, +} + export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = { // Game style style: 'practice', diff --git a/apps/web/src/lib/arcade/game-registry.ts b/apps/web/src/lib/arcade/game-registry.ts index 48ce3f11..f83816ed 100644 --- a/apps/web/src/lib/arcade/game-registry.ts +++ b/apps/web/src/lib/arcade/game-registry.ts @@ -112,6 +112,7 @@ import { complementRaceGame } from '@/arcade-games/complement-race/index' import { cardSortingGame } from '@/arcade-games/card-sorting' import { yjsDemoGame } from '@/arcade-games/yjs-demo' import { rithmomachiaGame } from '@/arcade-games/rithmomachia' +import { knowYourWorldGame } from '@/arcade-games/know-your-world' registerGame(memoryQuizGame) registerGame(matchingGame) @@ -119,3 +120,4 @@ registerGame(complementRaceGame) registerGame(cardSortingGame) registerGame(yjsDemoGame) registerGame(rithmomachiaGame) +registerGame(knowYourWorldGame) diff --git a/apps/web/src/lib/arcade/validators.ts b/apps/web/src/lib/arcade/validators.ts index 9c4f1898..27017f64 100644 --- a/apps/web/src/lib/arcade/validators.ts +++ b/apps/web/src/lib/arcade/validators.ts @@ -16,6 +16,7 @@ import { complementRaceValidator } from '@/arcade-games/complement-race/Validato import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator' import { yjsDemoValidator } from '@/arcade-games/yjs-demo/Validator' import { rithmomachiaValidator } from '@/arcade-games/rithmomachia/Validator' +import { knowYourWorldValidator } from '@/arcade-games/know-your-world/Validator' import type { GameValidator } from './validation/types' /** @@ -30,6 +31,7 @@ export const validatorRegistry = { 'card-sorting': cardSortingValidator, 'yjs-demo': yjsDemoValidator, rithmomachia: rithmomachiaValidator, + 'know-your-world': knowYourWorldValidator, // Add new games here - GameName type will auto-update } as const @@ -103,4 +105,5 @@ export { cardSortingValidator, yjsDemoValidator, rithmomachiaValidator, + knowYourWorldValidator, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbd0e2e3..72c5f362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,12 @@ importers: '@soroban/templates': specifier: workspace:* version: link:../../packages/templates + '@svg-maps/usa': + specifier: ^2.0.0 + version: 2.0.0 + '@svg-maps/world': + specifier: ^2.0.0 + version: 2.0.0 '@tanstack/react-form': specifier: ^0.19.0 version: 0.19.5(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3621,6 +3627,12 @@ packages: '@storybook/types@7.6.20': resolution: {integrity: sha512-GncdY3x0LpbhmUAAJwXYtJDUQEwfF175gsjH0/fxPkxPoV7Sef9TM41jQLJW/5+6TnZoCZP/+aJZTJtq3ni23Q==} + '@svg-maps/usa@2.0.0': + resolution: {integrity: sha512-UnuPkScA4ITCOzVd722IsI/YoKauR7Di9Qcetuj1nEbobUbe8K7RVKslYP8UO3N2g2EUAz8QFvKNiMFtUG6GfQ==} + + '@svg-maps/world@2.0.0': + resolution: {integrity: sha512-VmYYnyygJ5Zhr48xPqukFBoq4aCBgVNIjBzqBnWKXSdHhrRK3pntdvbCa8lMsxwVCFzEw9a+smIMEGI2sC1xcQ==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -13806,6 +13818,10 @@ snapshots: '@types/express': 4.17.23 file-system-cache: 2.3.0 + '@svg-maps/usa@2.0.0': {} + + '@svg-maps/world@2.0.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5':