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:
parent
ff04312232
commit
25e24a7cbc
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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)
|
||||
`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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: {}
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue