feat: add Know Your World geography quiz game

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-18 15:44:47 -06:00
parent ff04312232
commit 25e24a7cbc
19 changed files with 2839 additions and 0 deletions

View File

@ -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",

View File

@ -0,0 +1,13 @@
'use client'
import { knowYourWorldGame } from '@/arcade-games/know-your-world'
const { Provider, GameComponent } = knowYourWorldGame
export default function KnowYourWorldPage() {
return (
<Provider>
<GameComponent />
</Provider>
)
}

View File

@ -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<KnowYourWorldContextValue | null>(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<KnowYourWorldState>({
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<string, any>) || {}
const currentConfig = (currentGameConfig['know-your-world'] as Record<string, any>) || {}
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<string, any>) || {}
const currentConfig = (currentGameConfig['know-your-world'] as Record<string, any>) || {}
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<string, any>) || {}
const currentConfig = (currentGameConfig['know-your-world'] as Record<string, any>) || {}
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<string, any>) || {}
const currentConfig = (currentGameConfig['know-your-world'] as Record<string, any>) || {}
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 (
<KnowYourWorldContext.Provider
value={{
state,
lastError,
clearError,
exitSession,
startGame,
clickRegion,
nextRound,
endGame,
endStudy,
returnToSetup,
setMap,
setMode,
setDifficulty,
setStudyDuration,
}}
>
{children}
</KnowYourWorldContext.Provider>
)
}

View File

@ -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<KnowYourWorldState, KnowYourWorldMove>
{
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<string, number> = {}
const attempts: Record<string, number> = {}
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<string, number> = {}
const attempts: Record<string, number> = {}
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<T>(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()

View File

@ -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 (
<PageWithNav
navTitle="Know Your World"
navEmoji="🌍"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={currentPlayerId}
playerScores={state.scores}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
onSetup={state.gamePhase !== 'setup' ? returnToSetup : undefined}
onNewGame={state.gamePhase !== 'setup' && state.gamePhase !== 'results' ? endGame : undefined}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'studying' && <StudyPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}

View File

@ -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<string | null>(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 (
<div
data-component="map-renderer"
className={css({
width: '100%',
maxWidth: '1000px',
margin: '0 auto',
padding: '4',
bg: isDark ? 'gray.900' : 'gray.50',
rounded: 'xl',
shadow: 'lg',
})}
>
<svg
viewBox={mapData.viewBox}
className={css({
width: '100%',
height: 'auto',
cursor: 'pointer',
})}
>
{/* Background */}
<rect x="0" y="0" width="100%" height="100%" fill={isDark ? '#111827' : '#f3f4f6'} />
{/* Render all regions */}
{mapData.regions.map((region) => (
<g key={region.id}>
{/* Region path */}
<path
d={region.path}
fill={getRegionColor(
region.id,
regionsFound.includes(region.id),
hoveredRegion === region.id,
isDark
)}
stroke={getRegionStroke(regionsFound.includes(region.id), isDark)}
strokeWidth={getRegionStrokeWidth(
hoveredRegion === region.id,
regionsFound.includes(region.id)
)}
opacity={showOutline(region) ? 1 : 0.3}
onMouseEnter={() => 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) && (
<text
x={region.center[0]}
y={region.center[1]}
textAnchor="middle"
dominantBaseline="middle"
fill={getLabelTextColor(isDark, true)}
fontSize="10"
fontWeight="bold"
pointerEvents="none"
style={{
textShadow: getLabelTextShadow(isDark, true),
}}
>
{region.name}
</text>
)}
</g>
))}
{/* Small region labels with arrows */}
{smallRegionLabels.map((label) => (
<g key={`label-${label.regionId}`}>
{/* Arrow line from label to region center */}
<line
x1={label.labelPosition[0] - 10}
y1={label.labelPosition[1]}
x2={label.regionCenter[0]}
y2={label.regionCenter[1]}
stroke={label.isFound ? '#16a34a' : isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth={2}
markerEnd="url(#arrowhead)"
pointerEvents="none"
/>
{/* Label background */}
<rect
x={label.labelPosition[0] - 5}
y={label.labelPosition[1] - 12}
width={label.regionName.length * 6 + 10}
height={20}
fill={label.isFound ? (isDark ? '#22c55e' : '#86efac') : (isDark ? '#1f2937' : '#ffffff')}
stroke={label.isFound ? '#16a34a' : (isDark ? '#60a5fa' : '#3b82f6')}
strokeWidth={2}
rx={4}
style={{
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onClick={() => onRegionClick(label.regionId, label.regionName)}
onMouseEnter={() => setHoveredRegion(label.regionId)}
onMouseLeave={() => setHoveredRegion(null)}
/>
{/* Label text */}
<text
x={label.labelPosition[0]}
y={label.labelPosition[1]}
textAnchor="start"
dominantBaseline="middle"
fill={getLabelTextColor(isDark, label.isFound)}
fontSize="11"
fontWeight="600"
style={{
cursor: 'pointer',
userSelect: 'none',
textShadow: label.isFound
? getLabelTextShadow(isDark, true)
: '0 0 2px rgba(0,0,0,0.5)',
}}
onClick={() => onRegionClick(label.regionId, label.regionName)}
onMouseEnter={() => setHoveredRegion(label.regionId)}
onMouseLeave={() => setHoveredRegion(null)}
>
{label.regionName}
</text>
</g>
))}
{/* Arrow marker definition */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
>
<polygon
points="0 0, 10 3, 0 6"
fill={isDark ? '#60a5fa' : '#3b82f6'}
/>
</marker>
</defs>
</svg>
</div>
)
}

View File

@ -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 (
<div
data-component="playing-phase"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
paddingTop: '20',
paddingX: '4',
paddingBottom: '4',
maxWidth: '1200px',
margin: '0 auto',
})}
>
{/* Current Prompt */}
<div
data-section="current-prompt"
className={css({
textAlign: 'center',
padding: '6',
bg: isDark ? 'blue.900' : 'blue.50',
rounded: 'xl',
border: '3px solid',
borderColor: 'blue.500',
})}
>
<div
className={css({
fontSize: 'sm',
color: isDark ? 'blue.300' : 'blue.700',
marginBottom: '2',
fontWeight: 'semibold',
})}
>
Find this location:
</div>
<div
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: isDark ? 'blue.100' : 'blue.900',
})}
>
{currentRegionName || '...'}
</div>
</div>
{/* Error Display */}
{lastError && (
<div
data-element="error-banner"
className={css({
padding: '4',
bg: 'red.100',
color: 'red.900',
rounded: 'lg',
border: '2px solid',
borderColor: 'red.500',
display: 'flex',
alignItems: 'center',
gap: '3',
})}
>
<span className={css({ fontSize: '2xl' })}></span>
<div className={css({ flex: '1' })}>
<div className={css({ fontWeight: 'bold' })}>Incorrect!</div>
<div className={css({ fontSize: 'sm' })}>{lastError}</div>
</div>
<button
onClick={clearError}
className={css({
padding: '2',
bg: 'red.200',
rounded: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
_hover: {
bg: 'red.300',
},
})}
>
Dismiss
</button>
</div>
)}
{/* Progress Bar */}
<div
data-section="progress"
className={css({
bg: isDark ? 'gray.800' : 'gray.200',
rounded: 'full',
height: '8',
overflow: 'hidden',
position: 'relative',
})}
>
<div
className={css({
bg: 'green.500',
height: '100%',
transition: 'width 0.5s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
style={{ width: `${progress}%` }}
>
<span
className={css({
fontSize: 'sm',
fontWeight: 'bold',
color: 'white',
})}
>
{foundCount} / {totalRegions}
</span>
</div>
</div>
{/* Map */}
<MapRenderer
mapData={mapData}
regionsFound={state.regionsFound}
currentPrompt={state.currentPrompt}
difficulty={state.difficulty}
onRegionClick={clickRegion}
/>
{/* Game Mode Info */}
<div
data-section="game-info"
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '3',
textAlign: 'center',
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
<div>
<div className={css({ fontWeight: 'bold', color: isDark ? 'gray.300' : 'gray.700' })}>
Map
</div>
<div>{mapData.name}</div>
</div>
<div>
<div className={css({ fontWeight: 'bold', color: isDark ? 'gray.300' : 'gray.700' })}>
Mode
</div>
<div>
{state.gameMode === 'cooperative' && '🤝 Cooperative'}
{state.gameMode === 'race' && '🏁 Race'}
{state.gameMode === 'turn-based' && '↔️ Turn-Based'}
</div>
</div>
<div>
<div className={css({ fontWeight: 'bold', color: isDark ? 'gray.300' : 'gray.700' })}>
Difficulty
</div>
<div>
{state.difficulty === 'easy' && '😊 Easy'}
{state.difficulty === 'hard' && '🤔 Hard'}
</div>
</div>
</div>
{/* Turn Indicator (for turn-based mode) */}
{state.gameMode === 'turn-based' && (
<div
data-section="turn-indicator"
className={css({
textAlign: 'center',
padding: '3',
bg: isDark ? 'purple.900' : 'purple.50',
rounded: 'lg',
border: '2px solid',
borderColor: 'purple.500',
fontSize: 'lg',
fontWeight: 'semibold',
color: isDark ? 'purple.100' : 'purple.900',
})}
>
Current Turn: {state.playerMetadata[state.currentPlayer]?.name || state.currentPlayer}
</div>
)}
</div>
)
}

View File

@ -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 (
<div
data-component="results-phase"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '6',
maxWidth: '800px',
margin: '0 auto',
paddingTop: '20',
paddingX: '6',
paddingBottom: '6',
})}
>
{/* Victory Banner */}
<div
data-section="victory-banner"
className={css({
textAlign: 'center',
padding: '8',
bg: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
rounded: 'xl',
shadow: 'xl',
})}
>
<div className={css({ fontSize: '6xl', marginBottom: '4' })}>🎉</div>
<div className={css({ fontSize: '3xl', fontWeight: 'bold', color: 'white' })}>
{state.gameMode === 'cooperative' ? 'Great Teamwork!' : 'Winner!'}
</div>
{state.gameMode !== 'cooperative' && winner && (
<div className={css({ fontSize: '2xl', color: 'white', marginTop: '2' })}>
{winner.emoji} {winner.name}
</div>
)}
</div>
{/* Stats */}
<div
data-section="game-stats"
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
padding: '6',
shadow: 'lg',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
Game Statistics
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '4',
})}
>
<div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
Regions Found
</div>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'green.500' })}>
{totalRegions} / {totalRegions}
</div>
</div>
<div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
Time
</div>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'blue.500' })}>
{minutes}:{seconds.toString().padStart(2, '0')}
</div>
</div>
</div>
</div>
{/* Player Scores */}
<div
data-section="player-scores"
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
padding: '6',
shadow: 'lg',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{state.gameMode === 'cooperative' ? 'Team Score' : 'Leaderboard'}
</h2>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '3' })}>
{sortedPlayers.map((player, index) => (
<div
key={player.playerId}
data-element="player-score"
className={css({
display: 'flex',
alignItems: 'center',
gap: '4',
padding: '4',
bg:
index === 0
? isDark
? 'yellow.900'
: 'yellow.50'
: isDark
? 'gray.700'
: 'gray.100',
rounded: 'lg',
border: '2px solid',
borderColor: index === 0 ? 'yellow.500' : 'transparent',
})}
>
<div className={css({ fontSize: '3xl' })}>{player.emoji}</div>
<div className={css({ flex: '1' })}>
<div
className={css({
fontWeight: 'bold',
fontSize: 'lg',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
{player.name}
</div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
{player.attempts} wrong clicks
</div>
</div>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'green.500' })}>
{player.score} pts
</div>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(1, 1fr)',
gap: '4',
})}
>
<button
data-action="play-again"
onClick={nextRound}
className={css({
padding: '4',
rounded: 'xl',
bg: 'blue.600',
color: 'white',
fontSize: 'xl',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'blue.700',
transform: 'translateY(-2px)',
shadow: 'lg',
},
})}
>
🔄 Play Again
</button>
</div>
</div>
)
}

View File

@ -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 (
<div
data-component="setup-phase"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '6',
maxWidth: '800px',
margin: '0 auto',
paddingTop: '20',
paddingX: '6',
paddingBottom: '6',
})}
>
{/* Map Selection */}
<div data-section="map-selection">
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
Choose a Map 🗺
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '4',
})}
>
<button
data-action="select-world-map"
onClick={() => setMap('world')}
className={css({
padding: '6',
rounded: 'xl',
border: '3px solid',
borderColor: state.selectedMap === 'world' ? 'blue.500' : 'transparent',
bg:
state.selectedMap === 'world'
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
transform: 'translateY(-2px)',
},
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '2' })}>🌍</div>
<div className={css({ fontSize: 'xl', fontWeight: 'bold' })}>World</div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
256 countries
</div>
</button>
<button
data-action="select-usa-map"
onClick={() => setMap('usa')}
className={css({
padding: '6',
rounded: 'xl',
border: '3px solid',
borderColor: state.selectedMap === 'usa' ? 'blue.500' : 'transparent',
bg:
state.selectedMap === 'usa'
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
transform: 'translateY(-2px)',
},
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '2' })}>🇺🇸</div>
<div className={css({ fontSize: 'xl', fontWeight: 'bold' })}>USA States</div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
51 states
</div>
</button>
</div>
</div>
{/* Mode Selection */}
<div data-section="mode-selection">
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
Game Mode 🎮
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '4',
})}
>
<button
data-action="select-cooperative-mode"
onClick={() => setMode('cooperative')}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.gameMode === 'cooperative' ? 'green.500' : 'transparent',
bg:
state.gameMode === 'cooperative'
? isDark
? 'green.900'
: 'green.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'green.400',
},
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '2' })}>🤝</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Cooperative</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Work together
</div>
</button>
<button
data-action="select-race-mode"
onClick={() => setMode('race')}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.gameMode === 'race' ? 'orange.500' : 'transparent',
bg:
state.gameMode === 'race'
? isDark
? 'orange.900'
: 'orange.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'orange.400',
},
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '2' })}>🏁</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Race</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
First to click wins
</div>
</button>
<button
data-action="select-turn-based-mode"
onClick={() => setMode('turn-based')}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.gameMode === 'turn-based' ? 'purple.500' : 'transparent',
bg:
state.gameMode === 'turn-based'
? isDark
? 'purple.900'
: 'purple.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'purple.400',
},
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '2' })}></div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Turn-Based</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Take turns
</div>
</button>
</div>
</div>
{/* Difficulty Selection */}
<div data-section="difficulty-selection">
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
Difficulty
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '4',
})}
>
<button
data-action="select-easy-difficulty"
onClick={() => setDifficulty('easy')}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.difficulty === 'easy' ? 'green.500' : 'transparent',
bg:
state.difficulty === 'easy'
? isDark
? 'green.900'
: 'green.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'green.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}>😊</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Easy</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
All outlines visible
</div>
</button>
<button
data-action="select-hard-difficulty"
onClick={() => setDifficulty('hard')}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.difficulty === 'hard' ? 'red.500' : 'transparent',
bg:
state.difficulty === 'hard'
? isDark
? 'red.900'
: 'red.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'red.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}>🤔</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Hard</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Outlines on hover only
</div>
</button>
</div>
</div>
{/* Study Mode Selection */}
<div data-section="study-mode-selection">
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '4',
color: isDark ? 'gray.100' : 'gray.900',
})}
>
Study Mode 📚
</h2>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '4',
})}
>
<button
data-action="select-no-study"
onClick={() => setStudyDuration(0)}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.studyDuration === 0 ? 'gray.500' : 'transparent',
bg:
state.studyDuration === 0
? isDark
? 'gray.700'
: 'gray.200'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}></div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>Skip</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
No study time
</div>
</button>
<button
data-action="select-30s-study"
onClick={() => setStudyDuration(30)}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.studyDuration === 30 ? 'blue.500' : 'transparent',
bg:
state.studyDuration === 30
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}></div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>30s</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Quick review
</div>
</button>
<button
data-action="select-60s-study"
onClick={() => setStudyDuration(60)}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.studyDuration === 60 ? 'blue.500' : 'transparent',
bg:
state.studyDuration === 60
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}></div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>1m</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Study time
</div>
</button>
<button
data-action="select-120s-study"
onClick={() => setStudyDuration(120)}
className={css({
padding: '4',
rounded: 'lg',
border: '2px solid',
borderColor: state.studyDuration === 120 ? 'blue.500' : 'transparent',
bg:
state.studyDuration === 120
? isDark
? 'blue.900'
: 'blue.50'
: isDark
? 'gray.800'
: 'gray.100',
color: isDark ? 'gray.100' : 'gray.900',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.400',
},
})}
>
<div className={css({ fontSize: '2xl', marginBottom: '2' })}></div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold' })}>2m</div>
<div className={css({ fontSize: 'xs', color: isDark ? 'gray.400' : 'gray.600' })}>
Deep study
</div>
</button>
</div>
</div>
{/* Start Button */}
<button
data-action="start-game"
onClick={startGame}
className={css({
marginTop: '4',
padding: '4',
rounded: 'xl',
bg: 'blue.600',
color: 'white',
fontSize: 'xl',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'blue.700',
transform: 'translateY(-2px)',
shadow: 'lg',
},
})}
>
{state.studyDuration > 0 ? 'Start Study & Play! 📚🚀' : 'Start Game! 🚀'}
</button>
</div>
)
}

View File

@ -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 (
<div
data-component="study-phase"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
paddingTop: '20',
paddingX: '4',
paddingBottom: '4',
maxWidth: '1200px',
margin: '0 auto',
})}
>
{/* Header with timer */}
<div
data-section="study-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '4',
bg: isDark ? 'blue.900' : 'blue.50',
rounded: 'xl',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
})}
>
<div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isDark ? 'blue.100' : 'blue.900',
})}
>
Study Time 📚
</h2>
<p
className={css({
fontSize: 'sm',
color: isDark ? 'blue.300' : 'blue.700',
})}
>
Memorize the locations - the quiz starts when the timer ends!
</p>
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: timeRemaining <= 10 ? (isDark ? 'red.400' : 'red.600') : isDark ? 'blue.200' : 'blue.800',
fontFeatureSettings: '"tnum"',
})}
>
{formatTime(timeRemaining)}
</div>
<button
data-action="skip-study"
onClick={endStudy}
className={css({
padding: '2',
paddingX: '4',
rounded: 'lg',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.200' : 'gray.800',
fontSize: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isDark ? 'gray.600' : 'gray.300',
},
})}
>
Skip to Game
</button>
</div>
</div>
{/* Map with all labels visible */}
<div
data-element="study-map"
className={css({
width: '100%',
maxWidth: '1000px',
margin: '0 auto',
padding: '4',
bg: isDark ? 'gray.900' : 'gray.50',
rounded: 'xl',
shadow: 'lg',
})}
>
<svg
viewBox={mapData.viewBox}
className={css({
width: '100%',
height: 'auto',
})}
>
{/* Background */}
<rect x="0" y="0" width="100%" height="100%" fill={isDark ? '#111827' : '#f3f4f6'} />
{/* Render all regions with labels */}
{mapData.regions.map((region: MapRegion) => (
<g key={region.id}>
{/* Region path */}
<path
d={region.path}
fill={getRegionColor(region.id, false, false, isDark)}
stroke={isDark ? '#1f2937' : '#ffffff'}
strokeWidth={0.5}
style={{
pointerEvents: 'none',
}}
/>
{/* Region label - ALWAYS visible during study */}
<text
x={region.center[0]}
y={region.center[1]}
textAnchor="middle"
dominantBaseline="middle"
fill={getLabelTextColor(isDark, false)}
fontSize="10"
fontWeight="bold"
pointerEvents="none"
style={{
textShadow: getLabelTextShadow(isDark, false),
}}
>
{region.name}
</text>
</g>
))}
</svg>
</div>
{/* Study tips */}
<div
className={css({
padding: '4',
bg: isDark ? 'gray.800' : 'gray.100',
rounded: 'lg',
fontSize: 'sm',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
<p className={css({ fontWeight: 'semibold', marginBottom: '2' })}>💡 Study Tips:</p>
<ul className={css({ listStyle: 'disc', paddingLeft: '5', display: 'flex', flexDirection: 'column', gap: '1' })}>
<li>Look for patterns - neighboring regions, shapes, sizes</li>
<li>Group regions mentally by area (e.g., Northeast, Southwest)</li>
<li>Focus on the tricky small ones that are hard to see</li>
<li>The quiz will be in random order - study them all!</li>
</ul>
</div>
</div>
)
}

View File

@ -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,
})

View File

@ -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)
`
}

View File

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

View File

@ -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<string, number> // playerId -> points
attempts: Record<string, number> // playerId -> total wrong clicks
guessHistory: GuessRecord[] // Complete history of all guesses
// Timing
startTime: number
endTime?: number
// Multiplayer
activePlayers: string[]
playerMetadata: Record<string, any>
}
// Move types
export type KnowYourWorldMove =
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
activePlayers: string[]
playerMetadata: Record<string, any>
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: {}
}

View File

@ -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}`)
}

View File

@ -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<typeof yjsDemoGame>
*/
export type RithmomachiaGameConfig = InferGameConfig<typeof rithmomachiaGame>
/**
* Configuration for know-your-world (Geography Quiz) game
* INFERRED from knowYourWorldGame.defaultConfig
*/
export type KnowYourWorldConfig = InferGameConfig<typeof knowYourWorldGame>
// ============================================================================
// 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',

View File

@ -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)

View File

@ -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,
}

View File

@ -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':