feat(know-your-world): separate region filtering from assistance level
Split the "difficulty" setting into two distinct concepts: - Region sizes: Checkbox selection for which regions to include (huge, large, medium, small, tiny) - Assistance level: Dropdown for gameplay features (hints, hot/cold feedback, etc.) Changes: - Add region size checkboxes with per-category counts in SetupPhase - Add assistance level dropdown with feature badges - Update Validator to handle new move types (SET_REGION_SIZES, SET_ASSISTANCE_LEVEL) - Show excluded regions as dimmed/grayed out on the setup map - Update MapRenderer to use assistanceLevel instead of difficulty - Update game-configs.ts with new default config fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a6f8dbe474
commit
9499e4e8b5
|
|
@ -10,7 +10,8 @@ import {
|
|||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import { buildPlayerOwnershipFromRoomData } from '@/lib/arcade/player-ownership.client'
|
||||
import type { KnowYourWorldState, KnowYourWorldMove } from './types'
|
||||
import type { KnowYourWorldState, AssistanceLevel } from './types'
|
||||
import type { RegionSize } from './maps'
|
||||
|
||||
interface KnowYourWorldContextValue {
|
||||
state: KnowYourWorldState
|
||||
|
|
@ -24,13 +25,15 @@ interface KnowYourWorldContextValue {
|
|||
nextRound: () => void
|
||||
endGame: () => void
|
||||
giveUp: () => void
|
||||
requestHint: () => void
|
||||
endStudy: () => void
|
||||
returnToSetup: () => void
|
||||
|
||||
// Setup actions
|
||||
setMap: (map: 'world' | 'usa') => void
|
||||
setMode: (mode: 'cooperative' | 'race' | 'turn-based') => void
|
||||
setDifficulty: (difficulty: string) => void
|
||||
setRegionSizes: (sizes: RegionSize[]) => void
|
||||
setAssistanceLevel: (level: AssistanceLevel) => void
|
||||
setStudyDuration: (duration: 0 | 30 | 60 | 120) => void
|
||||
setContinent: (continent: import('./continents').ContinentId | 'all') => void
|
||||
|
||||
|
|
@ -94,11 +97,27 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
? (rawContinent as any)
|
||||
: 'all'
|
||||
|
||||
// Validate includeSizes - should be an array of valid size strings
|
||||
const validSizes: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
|
||||
const rawSizes = gameConfig?.includeSizes
|
||||
const includeSizes: RegionSize[] = Array.isArray(rawSizes)
|
||||
? rawSizes.filter((s: string) => validSizes.includes(s as RegionSize))
|
||||
: ['huge', 'large', 'medium'] // Default to most regions
|
||||
|
||||
// Validate assistanceLevel
|
||||
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
|
||||
const rawAssistance = gameConfig?.assistanceLevel
|
||||
const assistanceLevel: AssistanceLevel =
|
||||
typeof rawAssistance === 'string' && validAssistanceLevels.includes(rawAssistance)
|
||||
? (rawAssistance as AssistanceLevel)
|
||||
: 'helpful'
|
||||
|
||||
return {
|
||||
gamePhase: 'setup' as const,
|
||||
selectedMap: (gameConfig?.selectedMap as 'world' | 'usa') || 'world',
|
||||
gameMode: (gameConfig?.gameMode as 'cooperative' | 'race' | 'turn-based') || 'cooperative',
|
||||
difficulty: gameConfig?.difficulty || 'medium',
|
||||
includeSizes,
|
||||
assistanceLevel,
|
||||
studyDuration,
|
||||
selectedContinent,
|
||||
studyTimeRemaining: 0,
|
||||
|
|
@ -117,6 +136,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
playerMetadata: {},
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
}
|
||||
}, [roomData])
|
||||
|
||||
|
|
@ -170,7 +191,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
playerMetadata,
|
||||
selectedMap: state.selectedMap,
|
||||
gameMode: state.gameMode,
|
||||
difficulty: state.difficulty,
|
||||
includeSizes: state.includeSizes,
|
||||
assistanceLevel: state.assistanceLevel,
|
||||
},
|
||||
})
|
||||
}, [
|
||||
|
|
@ -181,7 +203,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
sendMove,
|
||||
state.selectedMap,
|
||||
state.gameMode,
|
||||
state.difficulty,
|
||||
state.includeSizes,
|
||||
state.assistanceLevel,
|
||||
])
|
||||
|
||||
// Action: Click Region
|
||||
|
|
@ -241,6 +264,16 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
})
|
||||
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
|
||||
|
||||
// Action: Request Hint (highlight current region briefly)
|
||||
const requestHint = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'REQUEST_HINT',
|
||||
playerId: state.currentPlayer || activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
|
||||
|
||||
// Setup Action: Set Map
|
||||
const setMap = useCallback(
|
||||
(selectedMap: 'world' | 'usa') => {
|
||||
|
|
@ -301,14 +334,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
[viewerId, sendMove, roomData, updateGameConfig, activePlayers]
|
||||
)
|
||||
|
||||
// Setup Action: Set Difficulty
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: string) => {
|
||||
// Setup Action: Set Region Sizes (which sizes to include)
|
||||
const setRegionSizes = useCallback(
|
||||
(includeSizes: RegionSize[]) => {
|
||||
sendMove({
|
||||
type: 'SET_DIFFICULTY',
|
||||
playerId: activePlayers[0] || '', // Use first active player
|
||||
type: 'SET_REGION_SIZES',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { difficulty },
|
||||
data: { includeSizes },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
|
|
@ -322,7 +355,37 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
...currentGameConfig,
|
||||
'know-your-world': {
|
||||
...currentConfig,
|
||||
difficulty,
|
||||
includeSizes,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, roomData, updateGameConfig, activePlayers]
|
||||
)
|
||||
|
||||
// Setup Action: Set Assistance Level
|
||||
const setAssistanceLevel = useCallback(
|
||||
(assistanceLevel: AssistanceLevel) => {
|
||||
sendMove({
|
||||
type: 'SET_ASSISTANCE_LEVEL',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { assistanceLevel },
|
||||
})
|
||||
|
||||
// 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,
|
||||
assistanceLevel,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -431,11 +494,13 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
nextRound,
|
||||
endGame,
|
||||
giveUp,
|
||||
requestHint,
|
||||
endStudy,
|
||||
returnToSetup,
|
||||
setMap,
|
||||
setMode,
|
||||
setDifficulty,
|
||||
setRegionSizes,
|
||||
setAssistanceLevel,
|
||||
setStudyDuration,
|
||||
setContinent,
|
||||
otherPlayerCursors,
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@ import type {
|
|||
KnowYourWorldMove,
|
||||
KnowYourWorldState,
|
||||
GuessRecord,
|
||||
AssistanceLevel,
|
||||
} from './types'
|
||||
import type { RegionSize } from './maps'
|
||||
|
||||
/**
|
||||
* Lazy-load map functions to avoid importing ES modules at module init time
|
||||
* This is critical for server-side usage where ES modules can't be required
|
||||
*/
|
||||
async function getFilteredMapDataLazy(
|
||||
...args: Parameters<typeof import('./maps').getFilteredMapData>
|
||||
async function getFilteredMapDataBySizesLazy(
|
||||
...args: Parameters<typeof import('./maps').getFilteredMapDataBySizes>
|
||||
) {
|
||||
const { getFilteredMapData } = await import('./maps')
|
||||
return getFilteredMapData(...args)
|
||||
const { getFilteredMapDataBySizes } = await import('./maps')
|
||||
return getFilteredMapDataBySizes(...args)
|
||||
}
|
||||
|
||||
export class KnowYourWorldValidator
|
||||
|
|
@ -41,14 +43,18 @@ export class KnowYourWorldValidator
|
|||
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_REGION_SIZES':
|
||||
return this.validateSetRegionSizes(state, move.data.includeSizes)
|
||||
case 'SET_ASSISTANCE_LEVEL':
|
||||
return this.validateSetAssistanceLevel(state, move.data.assistanceLevel)
|
||||
case 'SET_STUDY_DURATION':
|
||||
return this.validateSetStudyDuration(state, move.data.studyDuration)
|
||||
case 'SET_CONTINENT':
|
||||
return this.validateSetContinent(state, move.data.selectedContinent)
|
||||
case 'GIVE_UP':
|
||||
return await this.validateGiveUp(state, move.playerId, move.userId)
|
||||
case 'REQUEST_HINT':
|
||||
return this.validateRequestHint(state, move.playerId, move.timestamp)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
|
|
@ -63,14 +69,19 @@ export class KnowYourWorldValidator
|
|||
return { valid: false, error: 'Can only start from setup phase' }
|
||||
}
|
||||
|
||||
const { activePlayers, playerMetadata, selectedMap, gameMode, difficulty } = data
|
||||
const { activePlayers, playerMetadata, selectedMap, gameMode, includeSizes, assistanceLevel } =
|
||||
data
|
||||
|
||||
if (!activePlayers || activePlayers.length === 0) {
|
||||
return { valid: false, error: 'Need at least 1 player' }
|
||||
}
|
||||
|
||||
// Get map data and shuffle regions (with continent and difficulty filters)
|
||||
const mapData = await getFilteredMapDataLazy(selectedMap, state.selectedContinent, difficulty)
|
||||
// Get map data and shuffle regions (with continent and size filters)
|
||||
const mapData = await getFilteredMapDataBySizesLazy(
|
||||
selectedMap,
|
||||
state.selectedContinent,
|
||||
includeSizes || state.includeSizes
|
||||
)
|
||||
const regionIds = mapData.regions.map((r) => r.id)
|
||||
const shuffledRegions = this.shuffleArray([...regionIds])
|
||||
|
||||
|
|
@ -96,7 +107,8 @@ export class KnowYourWorldValidator
|
|||
playerMetadata,
|
||||
selectedMap,
|
||||
gameMode,
|
||||
difficulty,
|
||||
includeSizes: includeSizes || state.includeSizes,
|
||||
assistanceLevel: assistanceLevel || state.assistanceLevel,
|
||||
studyTimeRemaining: shouldStudy ? state.studyDuration : 0,
|
||||
studyStartTime: shouldStudy ? Date.now() : 0,
|
||||
currentPrompt: shouldStudy ? null : shuffledRegions[0],
|
||||
|
|
@ -110,6 +122,8 @@ export class KnowYourWorldValidator
|
|||
startTime: Date.now(),
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -179,6 +193,7 @@ export class KnowYourWorldValidator
|
|||
endTime: Date.now(),
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [], // Clear votes when game ends
|
||||
hintActive: null,
|
||||
activeUserIds,
|
||||
}
|
||||
return { valid: true, newState }
|
||||
|
|
@ -206,6 +221,7 @@ export class KnowYourWorldValidator
|
|||
guessHistory,
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [], // Clear votes when moving to next region
|
||||
hintActive: null, // Clear hint when moving to next region
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +252,9 @@ export class KnowYourWorldValidator
|
|||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
error: `Incorrect! Try again. Looking for: ${state.currentPrompt}`,
|
||||
// Error message includes clicked region name for client to format based on difficulty
|
||||
// Format: "CLICKED:[regionName]" so client can parse and format appropriately
|
||||
error: `CLICKED:${regionName}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -255,11 +273,11 @@ export class KnowYourWorldValidator
|
|||
return { valid: false, error: 'Can only start next round from results' }
|
||||
}
|
||||
|
||||
// Get map data and shuffle regions (with continent and difficulty filters)
|
||||
const mapData = await getFilteredMapDataLazy(
|
||||
// Get map data and shuffle regions (with continent and size filters)
|
||||
const mapData = await getFilteredMapDataBySizesLazy(
|
||||
state.selectedMap,
|
||||
state.selectedContinent,
|
||||
state.difficulty
|
||||
state.includeSizes
|
||||
)
|
||||
const regionIds = mapData.regions.map((r) => r.id)
|
||||
const shuffledRegions = this.shuffleArray([...regionIds])
|
||||
|
|
@ -292,6 +310,8 @@ export class KnowYourWorldValidator
|
|||
endTime: undefined,
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -340,14 +360,33 @@ export class KnowYourWorldValidator
|
|||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetDifficulty(state: KnowYourWorldState, difficulty: string): ValidationResult {
|
||||
private validateSetRegionSizes(
|
||||
state: KnowYourWorldState,
|
||||
includeSizes: RegionSize[]
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change difficulty during setup' }
|
||||
return { valid: false, error: 'Can only change region sizes during setup' }
|
||||
}
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
difficulty,
|
||||
includeSizes,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetAssistanceLevel(
|
||||
state: KnowYourWorldState,
|
||||
assistanceLevel: AssistanceLevel
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change assistance level during setup' }
|
||||
}
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
assistanceLevel,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
|
|
@ -493,10 +532,10 @@ export class KnowYourWorldValidator
|
|||
activeUserIds: string[]
|
||||
): Promise<ValidationResult> {
|
||||
// Get region info for the reveal
|
||||
const mapData = await getFilteredMapDataLazy(
|
||||
const mapData = await getFilteredMapDataBySizesLazy(
|
||||
state.selectedMap,
|
||||
state.selectedContinent,
|
||||
state.difficulty
|
||||
state.includeSizes
|
||||
)
|
||||
const region = mapData.regions.find((r) => r.id === state.currentPrompt)
|
||||
|
||||
|
|
@ -511,10 +550,11 @@ export class KnowYourWorldValidator
|
|||
? existingGivenUp
|
||||
: [...existingGivenUp, region.id]
|
||||
|
||||
// Determine re-ask position based on difficulty
|
||||
// Easy: re-ask soon (after 2-3 regions)
|
||||
// Hard: re-ask at the end
|
||||
const reaskDelay = state.difficulty === 'easy' ? 3 : state.regionsToFind.length
|
||||
// Determine re-ask position based on assistance level
|
||||
// Guided/Helpful: re-ask soon (after 2-3 regions) to reinforce learning
|
||||
// Standard/None: re-ask at the end
|
||||
const isHighAssistance = state.assistanceLevel === 'guided' || state.assistanceLevel === 'helpful'
|
||||
const reaskDelay = isHighAssistance ? 3 : state.regionsToFind.length
|
||||
|
||||
// Build new regions queue: take next regions, then insert given-up region at appropriate position
|
||||
const remainingRegions = [...state.regionsToFind]
|
||||
|
|
@ -547,12 +587,44 @@ export class KnowYourWorldValidator
|
|||
timestamp: Date.now(),
|
||||
},
|
||||
giveUpVotes: [], // Clear votes after give up is executed
|
||||
hintActive: null, // Clear hint when moving to next region
|
||||
activeUserIds,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateRequestHint(
|
||||
state: KnowYourWorldState,
|
||||
playerId: string,
|
||||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only request hints during playing phase' }
|
||||
}
|
||||
|
||||
if (!state.currentPrompt) {
|
||||
return { valid: false, error: 'No region to hint' }
|
||||
}
|
||||
|
||||
// For turn-based: check if it's this player's turn
|
||||
if (state.gameMode === 'turn-based' && state.currentPlayer !== playerId) {
|
||||
return { valid: false, error: 'Not your turn' }
|
||||
}
|
||||
|
||||
// Set hint active with current region
|
||||
const newState: KnowYourWorldState = {
|
||||
...state,
|
||||
hintsUsed: (state.hintsUsed ?? 0) + 1,
|
||||
hintActive: {
|
||||
regionId: state.currentPrompt,
|
||||
timestamp,
|
||||
},
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: KnowYourWorldState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
|
@ -560,11 +632,28 @@ export class KnowYourWorldValidator
|
|||
getInitialState(config: unknown): KnowYourWorldState {
|
||||
const typedConfig = config as KnowYourWorldConfig
|
||||
|
||||
// Validate includeSizes - should be an array of valid size strings
|
||||
const validSizes: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
|
||||
const rawSizes = typedConfig?.includeSizes
|
||||
const includeSizes: RegionSize[] = Array.isArray(rawSizes)
|
||||
? rawSizes.filter((s: string) => validSizes.includes(s as RegionSize))
|
||||
: ['huge', 'large', 'medium'] // Default
|
||||
|
||||
// Validate assistanceLevel
|
||||
const validAssistanceLevels: AssistanceLevel[] = ['guided', 'helpful', 'standard', 'none']
|
||||
const rawAssistance = typedConfig?.assistanceLevel
|
||||
const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes(
|
||||
rawAssistance as AssistanceLevel
|
||||
)
|
||||
? (rawAssistance as AssistanceLevel)
|
||||
: 'helpful' // Default
|
||||
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
selectedMap: typedConfig?.selectedMap || 'world',
|
||||
gameMode: typedConfig?.gameMode || 'cooperative',
|
||||
difficulty: typedConfig?.difficulty || 'easy',
|
||||
includeSizes,
|
||||
assistanceLevel,
|
||||
studyDuration: typedConfig?.studyDuration || 0,
|
||||
selectedContinent: typedConfig?.selectedContinent || 'all',
|
||||
studyTimeRemaining: 0,
|
||||
|
|
@ -583,6 +672,8 @@ export class KnowYourWorldValidator
|
|||
playerMetadata: {},
|
||||
giveUpReveal: null,
|
||||
giveUpVotes: [],
|
||||
hintsUsed: 0,
|
||||
hintActive: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import {
|
|||
getSubMapsForContinent,
|
||||
parseViewBox,
|
||||
calculateFitCropViewBox,
|
||||
getFilteredMapDataBySizesSync,
|
||||
} from '../maps'
|
||||
import type { RegionSize } from '../maps'
|
||||
import {
|
||||
CONTINENTS,
|
||||
getContinentForCountry,
|
||||
|
|
@ -30,17 +32,29 @@ import {
|
|||
*/
|
||||
export type SelectionPath = [] | [ContinentId] | [ContinentId, string]
|
||||
|
||||
/**
|
||||
* Planet data type for joke placeholder
|
||||
*/
|
||||
interface PlanetData {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
size: number
|
||||
hasStripes?: boolean
|
||||
hasRings?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Joke placeholder: Other planets for when viewing Earth (world map)
|
||||
* Just for fun - these don't actually do anything
|
||||
*/
|
||||
const PLANETS = [
|
||||
const PLANETS: PlanetData[] = [
|
||||
{ id: 'mercury', name: 'Mercury', color: '#b0b0b0', size: 0.38 },
|
||||
{ id: 'venus', name: 'Venus', color: '#e6c87a', size: 0.95 },
|
||||
{ id: 'mars', name: 'Mars', color: '#c1440e', size: 0.53 },
|
||||
{ id: 'jupiter', name: 'Jupiter', color: '#d8ca9d', size: 2.0, hasStripes: true },
|
||||
{ id: 'saturn', name: 'Saturn', color: '#ead6b8', size: 1.7, hasRings: true },
|
||||
] as const
|
||||
]
|
||||
|
||||
interface DrillDownMapSelectorProps {
|
||||
/** Callback when selection changes (map/continent for game start) */
|
||||
|
|
@ -51,6 +65,8 @@ interface DrillDownMapSelectorProps {
|
|||
selectedMap: 'world' | 'usa'
|
||||
/** Current selected continent (for initial state sync) */
|
||||
selectedContinent: ContinentId | 'all'
|
||||
/** Region sizes to include (for showing excluded regions dimmed) */
|
||||
includeSizes: RegionSize[]
|
||||
}
|
||||
|
||||
interface BreadcrumbItem {
|
||||
|
|
@ -65,6 +81,7 @@ export function DrillDownMapSelector({
|
|||
onStartGame,
|
||||
selectedMap,
|
||||
selectedContinent,
|
||||
includeSizes,
|
||||
}: DrillDownMapSelectorProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
|
@ -216,6 +233,40 @@ export function DrillDownMapSelector({
|
|||
return groups
|
||||
}, [currentLevel])
|
||||
|
||||
// Calculate excluded regions based on includeSizes
|
||||
// These are regions that exist but are filtered out by size settings
|
||||
const excludedRegions = useMemo(() => {
|
||||
// Determine which map we're looking at
|
||||
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
|
||||
const continentId: ContinentId | 'all' =
|
||||
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
|
||||
|
||||
// Get all regions (unfiltered by size)
|
||||
const allRegionsMapData = getFilteredMapDataBySizesSync(
|
||||
mapId as 'world' | 'usa',
|
||||
continentId,
|
||||
['huge', 'large', 'medium', 'small', 'tiny'] // All sizes
|
||||
)
|
||||
const allRegionIds = new Set(allRegionsMapData.regions.map((r) => r.id))
|
||||
|
||||
// Get filtered regions (based on current includeSizes)
|
||||
const filteredMapData = getFilteredMapDataBySizesSync(
|
||||
mapId as 'world' | 'usa',
|
||||
continentId,
|
||||
includeSizes
|
||||
)
|
||||
const filteredRegionIds = new Set(filteredMapData.regions.map((r) => r.id))
|
||||
|
||||
// Excluded = all regions minus filtered regions
|
||||
const excluded: string[] = []
|
||||
for (const regionId of allRegionIds) {
|
||||
if (!filteredRegionIds.has(regionId)) {
|
||||
excluded.push(regionId)
|
||||
}
|
||||
}
|
||||
return excluded
|
||||
}, [currentLevel, path, selectedContinent, includeSizes])
|
||||
|
||||
// Compute the label to display for the hovered region
|
||||
// Shows the next drill-down level name, not the individual region name
|
||||
const hoveredLabel = useMemo(() => {
|
||||
|
|
@ -562,6 +613,7 @@ export function DrillDownMapSelector({
|
|||
currentLevel === 0 && selectedContinent !== 'all' ? selectedContinent : null
|
||||
}
|
||||
hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined}
|
||||
excludedRegions={excludedRegions}
|
||||
/>
|
||||
|
||||
{/* Zoom Out Button - positioned inside map, upper right */}
|
||||
|
|
@ -679,7 +731,8 @@ export function DrillDownMapSelector({
|
|||
{peers.map((peer) => {
|
||||
// Check if this is a planet (joke at world level)
|
||||
const isPlanet = 'isPlanet' in peer && peer.isPlanet
|
||||
const planetData = 'planetData' in peer ? peer.planetData : null
|
||||
const planetData =
|
||||
'planetData' in peer ? (peer.planetData as PlanetData | null) : null
|
||||
|
||||
// Calculate viewBox for this peer's continent (only for non-planets)
|
||||
const peerContinentId = peer.path[0]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react'
|
||||
import { css } from '@styled/css'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useKnowYourWorld } from '../Provider'
|
||||
import type { MapData } from '../types'
|
||||
import { getCountryFlagEmoji } from '../maps'
|
||||
import { getCountryFlagEmoji, WORLD_MAP, USA_MAP, DEFAULT_DIFFICULTY_CONFIG } from '../maps'
|
||||
|
||||
// Animation duration in ms - must match MapRenderer
|
||||
const GIVE_UP_ANIMATION_DURATION = 2000
|
||||
|
|
@ -34,11 +34,62 @@ export function GameInfoPanel({
|
|||
selectedMap === 'world' && currentRegionId ? getCountryFlagEmoji(currentRegionId) : ''
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { state, lastError, clearError, giveUp } = useKnowYourWorld()
|
||||
const { state, lastError, clearError, giveUp, requestHint } = useKnowYourWorld()
|
||||
|
||||
// Get current difficulty level config
|
||||
const currentDifficultyLevel = useMemo(() => {
|
||||
const mapDiffConfig =
|
||||
(selectedMap === 'world' ? WORLD_MAP : USA_MAP).difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
|
||||
return (
|
||||
mapDiffConfig.levels.find((level) => level.id === state.difficulty) || mapDiffConfig.levels[0]
|
||||
)
|
||||
}, [selectedMap, state.difficulty])
|
||||
|
||||
// Parse error message and format based on difficulty config
|
||||
const formattedError = useMemo(() => {
|
||||
if (!lastError) return null
|
||||
|
||||
// Check for "CLICKED:" prefix which indicates a wrong click
|
||||
if (lastError.startsWith('CLICKED:')) {
|
||||
const regionName = lastError.slice('CLICKED:'.length)
|
||||
if (currentDifficultyLevel?.wrongClickShowsName) {
|
||||
return `That was ${regionName}`
|
||||
}
|
||||
return null // Just show "Wrong!" without region name
|
||||
}
|
||||
|
||||
// Other errors pass through as-is
|
||||
return lastError
|
||||
}, [lastError, currentDifficultyLevel])
|
||||
|
||||
// Track if animation is in progress (local state based on timestamp)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
// Determine if hints are available based on difficulty config
|
||||
const hintsAvailable = useMemo(() => {
|
||||
const hintsMode = currentDifficultyLevel?.hintsMode
|
||||
if (hintsMode === 'none') return false
|
||||
if (hintsMode === 'limited') {
|
||||
const limit = currentDifficultyLevel?.hintLimit ?? 0
|
||||
return (state.hintsUsed ?? 0) < limit
|
||||
}
|
||||
return hintsMode === 'onRequest'
|
||||
}, [currentDifficultyLevel, state.hintsUsed])
|
||||
|
||||
// Calculate remaining hints for limited mode
|
||||
const remainingHints = useMemo(() => {
|
||||
if (currentDifficultyLevel?.hintsMode !== 'limited') return null
|
||||
const limit = currentDifficultyLevel?.hintLimit ?? 0
|
||||
return Math.max(0, limit - (state.hintsUsed ?? 0))
|
||||
}, [currentDifficultyLevel, state.hintsUsed])
|
||||
|
||||
// Handle hint request
|
||||
const handleHint = useCallback(() => {
|
||||
if (hintsAvailable && state.gamePhase === 'playing' && !isAnimating) {
|
||||
requestHint()
|
||||
}
|
||||
}, [hintsAvailable, state.gamePhase, isAnimating, requestHint])
|
||||
|
||||
// Check if animation is in progress based on timestamp
|
||||
useEffect(() => {
|
||||
if (!state.giveUpReveal?.timestamp) {
|
||||
|
|
@ -188,6 +239,51 @@ export function GameInfoPanel({
|
|||
{foundCount}/{totalRegions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint button - only show if hints are enabled */}
|
||||
{currentDifficultyLevel?.hintsMode !== 'none' && (
|
||||
<button
|
||||
data-action="request-hint"
|
||||
onClick={handleHint}
|
||||
disabled={!hintsAvailable || isAnimating || state.gamePhase !== 'playing'}
|
||||
className={css({
|
||||
padding: '2',
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
bg: hintsAvailable
|
||||
? isDark
|
||||
? 'yellow.800'
|
||||
: 'yellow.100'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.200',
|
||||
color: hintsAvailable
|
||||
? isDark
|
||||
? 'yellow.200'
|
||||
: 'yellow.800'
|
||||
: isDark
|
||||
? 'gray.500'
|
||||
: 'gray.500',
|
||||
border: '2px solid',
|
||||
borderColor: hintsAvailable ? 'yellow.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
rounded: 'md',
|
||||
cursor: hintsAvailable ? 'pointer' : 'not-allowed',
|
||||
opacity: hintsAvailable ? 1 : 0.6,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.5',
|
||||
_hover: hintsAvailable ? { bg: isDark ? 'yellow.700' : 'yellow.200' } : {},
|
||||
})}
|
||||
title={remainingHints !== null ? `${remainingHints} hints remaining` : 'Show hint'}
|
||||
>
|
||||
<span>💡</span>
|
||||
{remainingHints !== null && (
|
||||
<span className={css({ fontSize: '2xs' })}>{remainingHints}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display - only shows when error exists */}
|
||||
|
|
@ -208,7 +304,9 @@ export function GameInfoPanel({
|
|||
})}
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<div className={css({ flex: 1, fontWeight: 'bold' })}>Wrong! {lastError}</div>
|
||||
<div className={css({ flex: 1, fontWeight: 'bold' })}>
|
||||
Wrong!{formattedError ? ` ${formattedError}` : ''}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
|
|
@ -270,7 +368,11 @@ export function GameInfoPanel({
|
|||
{state.gameMode === 'turn-based' && '↔️'}
|
||||
</span>
|
||||
<span>
|
||||
{state.difficulty === 'learning' && '🌱'}
|
||||
{state.difficulty === 'easy' && '😊'}
|
||||
{state.difficulty === 'normal' && '🎯'}
|
||||
{state.difficulty === 'expert' && '🏆'}
|
||||
{/* Legacy fallback */}
|
||||
{state.difficulty === 'hard' && '🤔'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -100,19 +100,23 @@ const Template = (args: StoryArgs) => {
|
|||
correct: true,
|
||||
}))
|
||||
|
||||
// Map difficulty to assistance level for rendering
|
||||
const assistanceLevel = args.difficulty === 'easy' ? 'helpful' : 'standard'
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', minHeight: '100vh', background: '#111827' }}>
|
||||
<MapRenderer
|
||||
mapData={mapData}
|
||||
regionsFound={regionsFound}
|
||||
currentPrompt={mapData.regions[5]?.id || null}
|
||||
difficulty={args.difficulty}
|
||||
assistanceLevel={assistanceLevel}
|
||||
selectedMap="world"
|
||||
selectedContinent={args.continent}
|
||||
onRegionClick={(id, name) => console.log('Clicked:', id, name)}
|
||||
guessHistory={guessHistory}
|
||||
playerMetadata={mockPlayerMetadata}
|
||||
giveUpReveal={null}
|
||||
hintActive={null}
|
||||
onGiveUp={() => console.log('Give Up clicked')}
|
||||
forceTuning={{
|
||||
showArrows: args.showArrows,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum
|
|||
import {
|
||||
WORLD_MAP,
|
||||
USA_MAP,
|
||||
ASSISTANCE_LEVELS,
|
||||
filterRegionsByContinent,
|
||||
parseViewBox,
|
||||
calculateFitCropViewBox,
|
||||
|
|
@ -200,7 +201,7 @@ interface MapRendererProps {
|
|||
mapData: MapData
|
||||
regionsFound: string[]
|
||||
currentPrompt: string | null
|
||||
difficulty: string // Difficulty level ID
|
||||
assistanceLevel: 'guided' | 'helpful' | 'standard' | 'none' // Controls gameplay features (hints, hot/cold)
|
||||
selectedMap: 'world' | 'usa' // Map ID for calculating excluded regions
|
||||
selectedContinent: string // Continent ID for calculating excluded regions
|
||||
onRegionClick: (regionId: string, regionName: string) => void
|
||||
|
|
@ -225,6 +226,11 @@ interface MapRendererProps {
|
|||
regionName: string
|
||||
timestamp: number
|
||||
} | null
|
||||
// Hint highlight animation
|
||||
hintActive: {
|
||||
regionId: string
|
||||
timestamp: number
|
||||
} | null
|
||||
// Give up callback
|
||||
onGiveUp: () => void
|
||||
// Force simulation tuning parameters
|
||||
|
|
@ -301,13 +307,14 @@ export function MapRenderer({
|
|||
mapData,
|
||||
regionsFound,
|
||||
currentPrompt,
|
||||
difficulty,
|
||||
assistanceLevel,
|
||||
selectedMap,
|
||||
selectedContinent,
|
||||
onRegionClick,
|
||||
guessHistory,
|
||||
playerMetadata,
|
||||
giveUpReveal,
|
||||
hintActive,
|
||||
onGiveUp,
|
||||
forceTuning = {},
|
||||
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
|
||||
|
|
@ -333,7 +340,7 @@ export function MapRenderer({
|
|||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Calculate excluded regions (regions filtered out by difficulty/continent)
|
||||
// Calculate excluded regions (regions filtered out by size/continent)
|
||||
const excludedRegions = useMemo(() => {
|
||||
// Get full unfiltered map data
|
||||
const fullMapData = selectedMap === 'world' ? WORLD_MAP : USA_MAP
|
||||
|
|
@ -348,26 +355,16 @@ export function MapRenderer({
|
|||
const includedRegionIds = new Set(mapData.regions.map((r) => r.id))
|
||||
const excluded = allRegions.filter((r) => !includedRegionIds.has(r.id))
|
||||
|
||||
console.log('[MapRenderer] Excluded regions by difficulty:', {
|
||||
total: allRegions.length,
|
||||
included: includedRegionIds.size,
|
||||
excluded: excluded.length,
|
||||
excludedNames: excluded.map((r) => r.name),
|
||||
})
|
||||
|
||||
// Debug: Check if Gibraltar is included or excluded
|
||||
const gibraltar = allRegions.find((r) => r.id === 'gi')
|
||||
const gibraltarIncluded = includedRegionIds.has('gi')
|
||||
if (gibraltar) {
|
||||
console.log('[Gibraltar Debug]', gibraltarIncluded ? '✅ INCLUDED' : '❌ EXCLUDED', {
|
||||
inFilteredMap: gibraltarIncluded,
|
||||
difficulty,
|
||||
continent: selectedContinent,
|
||||
})
|
||||
}
|
||||
|
||||
return excluded
|
||||
}, [mapData, selectedMap, selectedContinent, difficulty])
|
||||
}, [mapData, selectedMap, selectedContinent])
|
||||
|
||||
// Get current assistance level config
|
||||
const currentAssistanceLevel = useMemo(() => {
|
||||
return ASSISTANCE_LEVELS.find((level) => level.id === assistanceLevel) || ASSISTANCE_LEVELS[1] // Default to 'helpful'
|
||||
}, [assistanceLevel])
|
||||
|
||||
// Whether hot/cold is allowed by the assistance level (not user preference)
|
||||
const assistanceAllowsHotCold = currentAssistanceLevel?.hotColdEnabled ?? false
|
||||
|
||||
// Create a set of excluded region IDs for quick lookup
|
||||
const excludedRegionIds = useMemo(
|
||||
|
|
@ -464,6 +461,10 @@ export function MapRenderer({
|
|||
// Give up reveal animation state
|
||||
const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value
|
||||
const [isGiveUpAnimating, setIsGiveUpAnimating] = useState(false) // Track if animation in progress
|
||||
|
||||
// Hint animation state
|
||||
const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value
|
||||
const [isHintAnimating, setIsHintAnimating] = useState(false) // Track if animation in progress
|
||||
// Saved button position to prevent jumping during zoom animation
|
||||
const [savedButtonPosition, setSavedButtonPosition] = useState<{
|
||||
top: number
|
||||
|
|
@ -722,11 +723,13 @@ export function MapRenderer({
|
|||
const autoHintRef = useRef(autoHint)
|
||||
const autoSpeakRef = useRef(autoSpeak)
|
||||
const withAccentRef = useRef(withAccent)
|
||||
const hotColdEnabledRef = useRef(hotColdEnabled)
|
||||
// Hot/cold is only active when both: 1) assistance level allows it, 2) user has it enabled
|
||||
const effectiveHotColdEnabled = assistanceAllowsHotCold && hotColdEnabled
|
||||
const hotColdEnabledRef = useRef(effectiveHotColdEnabled)
|
||||
autoHintRef.current = autoHint
|
||||
autoSpeakRef.current = autoSpeak
|
||||
withAccentRef.current = withAccent
|
||||
hotColdEnabledRef.current = hotColdEnabled
|
||||
hotColdEnabledRef.current = effectiveHotColdEnabled
|
||||
|
||||
// Handle hint bubble and auto-speak when the prompt changes (new region to find)
|
||||
// Only runs when currentPrompt changes, not when settings change
|
||||
|
|
@ -747,6 +750,7 @@ export function MapRenderer({
|
|||
}, [currentPrompt, hasHint, hintText, isSpeechSupported, speakHint])
|
||||
|
||||
// Hot/cold audio feedback hook
|
||||
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
|
||||
// Use continent name for language lookup if available, otherwise use selectedMap
|
||||
const hotColdMapName = selectedContinent || selectedMap
|
||||
const {
|
||||
|
|
@ -754,7 +758,7 @@ export function MapRenderer({
|
|||
reset: resetHotCold,
|
||||
lastFeedbackType: hotColdFeedbackType,
|
||||
} = useHotColdFeedback({
|
||||
enabled: hotColdEnabled && !isTouchDevice,
|
||||
enabled: assistanceAllowsHotCold && hotColdEnabled && !isTouchDevice,
|
||||
targetRegionId: currentPrompt,
|
||||
isSpeaking,
|
||||
mapName: hotColdMapName,
|
||||
|
|
@ -921,12 +925,6 @@ export function MapRenderer({
|
|||
const cropRegion = parseViewBox(mapData.customCrop)
|
||||
|
||||
const result = calculateFitCropViewBox(originalBounds, cropRegion, containerAspect)
|
||||
console.log('[MapRenderer] Calculated displayViewBox:', {
|
||||
customCrop: mapData.customCrop,
|
||||
originalViewBox: mapData.originalViewBox,
|
||||
containerAspect: containerAspect.toFixed(2),
|
||||
result,
|
||||
})
|
||||
return result
|
||||
}, [mapData.customCrop, mapData.originalViewBox, mapData.viewBox, svgDimensions])
|
||||
|
||||
|
|
@ -1164,6 +1162,56 @@ export function MapRenderer({
|
|||
}
|
||||
}, [giveUpReveal?.timestamp]) // Re-run when timestamp changes
|
||||
|
||||
// Hint animation effect - brief pulse to highlight target region
|
||||
useEffect(() => {
|
||||
if (!hintActive) {
|
||||
setHintFlashProgress(0)
|
||||
setIsHintAnimating(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Track if this effect has been cleaned up
|
||||
let isCancelled = false
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
// Start animation
|
||||
setIsHintAnimating(true)
|
||||
|
||||
// Animation: 2 pulses over 1.5 seconds (shorter than give-up)
|
||||
const duration = 1500
|
||||
const pulses = 2
|
||||
const startTime = Date.now()
|
||||
|
||||
const animate = () => {
|
||||
if (isCancelled) return
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// Create pulsing effect: sin wave for smooth on/off
|
||||
const pulseProgress = Math.sin(progress * Math.PI * pulses * 2) * 0.5 + 0.5
|
||||
setHintFlashProgress(pulseProgress)
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
// Animation complete
|
||||
setHintFlashProgress(0)
|
||||
setIsHintAnimating(false)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
isCancelled = true
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}
|
||||
}, [hintActive?.timestamp]) // Re-run when timestamp changes
|
||||
|
||||
// Keyboard shortcuts - Shift for magnifier, H for hint
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -1569,15 +1617,7 @@ export function MapRenderer({
|
|||
setLabelPositions(positions)
|
||||
setSmallRegionLabelPositions(smallPositions)
|
||||
|
||||
// Debug: Log summary
|
||||
console.log('[MapRenderer] Label positions updated:', {
|
||||
mapId: mapData.id,
|
||||
totalRegions: mapData.regions.length,
|
||||
regularLabels: positions.length,
|
||||
smallRegionLabels: smallPositions.length,
|
||||
viewBox: mapData.viewBox,
|
||||
svgDimensions,
|
||||
})
|
||||
// Debug log removed to reduce spam
|
||||
}
|
||||
|
||||
// Small delay to ensure ghost elements are rendered
|
||||
|
|
@ -1604,10 +1644,10 @@ export function MapRenderer({
|
|||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
|
||||
const showOutline = (region: MapRegion): boolean => {
|
||||
// Easy mode: always show outlines
|
||||
if (difficulty === 'easy') return true
|
||||
// Guided/Helpful modes: always show outlines
|
||||
if (assistanceLevel === 'guided' || assistanceLevel === 'helpful') return true
|
||||
|
||||
// Medium/Hard mode: only show outline on hover or if found
|
||||
// Standard/None modes: only show outline on hover or if found
|
||||
return hoveredRegion === region.id || regionsFound.includes(region.id)
|
||||
}
|
||||
|
||||
|
|
@ -2055,9 +2095,7 @@ export function MapRenderer({
|
|||
svgRect.width
|
||||
)
|
||||
adaptiveZoom = Math.min(adaptiveZoom, maxZoom)
|
||||
console.log(
|
||||
`[Magnifier] Capping zoom at ${adaptiveZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px, would have been ${screenPixelRatio.toFixed(1)} px/px)`
|
||||
)
|
||||
// Zoom cap log removed to reduce spam
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2363,9 +2401,11 @@ export function MapRenderer({
|
|||
const isFound = regionsFound.includes(region.id) || isExcluded // Treat excluded as pre-found
|
||||
const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null
|
||||
const isBeingRevealed = giveUpReveal?.regionId === region.id
|
||||
const isBeingHinted = hintActive?.regionId === region.id
|
||||
|
||||
// Special styling for excluded regions (grayed out, pre-labeled)
|
||||
// Bright gold flash for give up reveal with high contrast
|
||||
// Cyan flash for hint
|
||||
const fill = isBeingRevealed
|
||||
? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity
|
||||
: isExcluded
|
||||
|
|
@ -2415,6 +2455,18 @@ export function MapRenderer({
|
|||
style={{ filter: 'blur(4px)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Glow effect for hint - cyan pulsing outline */}
|
||||
{isBeingHinted && (
|
||||
<path
|
||||
d={region.path}
|
||||
fill={`rgba(0, 200, 255, ${0.1 + hintFlashProgress * 0.3})`}
|
||||
stroke={`rgba(0, 200, 255, ${0.4 + hintFlashProgress * 0.6})`}
|
||||
strokeWidth={6}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={{ filter: 'blur(3px)' }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
{/* Network hover border (crisp outline in player color) */}
|
||||
{networkHover && !isBeingRevealed && (
|
||||
<path
|
||||
|
|
@ -4552,9 +4604,10 @@ export function MapRenderer({
|
|||
)
|
||||
})()}
|
||||
|
||||
{/* Hot/Cold button - only show on desktop with speech support */}
|
||||
{/* Hot/Cold button - only show on desktop with speech support when assistance level allows */}
|
||||
{isSpeechSupported &&
|
||||
!isTouchDevice &&
|
||||
assistanceAllowsHotCold &&
|
||||
(() => {
|
||||
if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ interface MapSelectorMapProps {
|
|||
* Use this to disable hover on non-interactive regions.
|
||||
*/
|
||||
hoverableRegions?: string[]
|
||||
/**
|
||||
* Regions that are excluded by region size filtering.
|
||||
* These will be shown dimmed/grayed out.
|
||||
*/
|
||||
excludedRegions?: string[]
|
||||
}
|
||||
|
||||
export function MapSelectorMap({
|
||||
|
|
@ -51,6 +56,7 @@ export function MapSelectorMap({
|
|||
regionGroups,
|
||||
selectedGroup,
|
||||
hoverableRegions,
|
||||
excludedRegions = [],
|
||||
}: MapSelectorMapProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
|
@ -92,12 +98,23 @@ export function MapSelectorMap({
|
|||
return thisGroup === selectedGroup
|
||||
}
|
||||
|
||||
// Check if a region is excluded by size filtering
|
||||
const isRegionExcluded = (regionId: string): boolean => {
|
||||
return excludedRegions.includes(regionId)
|
||||
}
|
||||
|
||||
// Get fill color for a region
|
||||
const getRegionFill = (regionId: string): string => {
|
||||
const isExcluded = isRegionExcluded(regionId)
|
||||
const isHovered = isRegionHighlighted(regionId)
|
||||
const isSelected = isRegionSelected(regionId)
|
||||
const hasSubMap = highlightedRegions.includes(regionId)
|
||||
|
||||
// Excluded regions are dimmed
|
||||
if (isExcluded) {
|
||||
return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions
|
||||
}
|
||||
|
||||
// Use the game's color algorithm
|
||||
const baseColor = getRegionColor(regionId, false, isHovered, isDark)
|
||||
|
||||
|
|
@ -122,10 +139,16 @@ export function MapSelectorMap({
|
|||
|
||||
// Get stroke color for a region
|
||||
const getRegionStroke = (regionId: string): string => {
|
||||
const isExcluded = isRegionExcluded(regionId)
|
||||
const isHovered = isRegionHighlighted(regionId)
|
||||
const isSelected = isRegionSelected(regionId)
|
||||
const hasSubMap = highlightedRegions.includes(regionId)
|
||||
|
||||
// Excluded regions get subtle stroke
|
||||
if (isExcluded) {
|
||||
return isDark ? '#374151' : '#d1d5db'
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return isDark ? '#60a5fa' : '#1d4ed8'
|
||||
}
|
||||
|
|
@ -191,27 +214,32 @@ export function MapSelectorMap({
|
|||
/>
|
||||
|
||||
{/* Render each region */}
|
||||
{displayRegions.map((region) => (
|
||||
<path
|
||||
key={region.id}
|
||||
data-region={region.id}
|
||||
d={region.path}
|
||||
fill={getRegionFill(region.id)}
|
||||
stroke={getRegionStroke(region.id)}
|
||||
strokeWidth={getRegionStrokeWidth(region.id)}
|
||||
onMouseEnter={() => onRegionHover(region.id)}
|
||||
onMouseLeave={() => onRegionHover(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRegionClick(region.id)
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{displayRegions.map((region) => {
|
||||
const isExcluded = excludedRegions.includes(region.id)
|
||||
return (
|
||||
<path
|
||||
key={region.id}
|
||||
data-region={region.id}
|
||||
data-excluded={isExcluded ? 'true' : undefined}
|
||||
d={region.path}
|
||||
fill={getRegionFill(region.id)}
|
||||
stroke={getRegionStroke(region.id)}
|
||||
strokeWidth={getRegionStrokeWidth(region.id)}
|
||||
onMouseEnter={() => onRegionHover(region.id)}
|
||||
onMouseLeave={() => onRegionHover(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRegionClick(region.id)
|
||||
}}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
pointerEvents: 'all',
|
||||
opacity: isExcluded ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -55,16 +55,16 @@ export function PlayingPhase() {
|
|||
const currentRegionName = currentRegion?.name ?? null
|
||||
const currentRegionId = currentRegion?.id ?? null
|
||||
|
||||
// Debug logging
|
||||
console.log('[PlayingPhase] Current prompt lookup:', {
|
||||
currentPrompt: state.currentPrompt,
|
||||
currentRegionName,
|
||||
difficulty: state.difficulty,
|
||||
totalFilteredRegions: mapData.regions.length,
|
||||
filteredRegionIds: mapData.regions.map((r) => r.id).slice(0, 10),
|
||||
regionsToFindCount: state.regionsToFind.length,
|
||||
regionsToFindSample: state.regionsToFind.slice(0, 5),
|
||||
})
|
||||
// Debug warning if prompt not found in filtered regions (indicates server/client filter mismatch)
|
||||
if (state.currentPrompt && !currentRegion) {
|
||||
console.warn('[PlayingPhase] Prompt not in filtered regions - server/client filter mismatch:', {
|
||||
currentPrompt: state.currentPrompt,
|
||||
difficulty: state.difficulty,
|
||||
selectedContinent: state.selectedContinent,
|
||||
clientFilteredCount: mapData.regions.length,
|
||||
serverRegionsToFindCount: state.regionsToFind.length,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -139,13 +139,14 @@ export function PlayingPhase() {
|
|||
mapData={mapData}
|
||||
regionsFound={state.regionsFound}
|
||||
currentPrompt={state.currentPrompt}
|
||||
difficulty={state.difficulty}
|
||||
assistanceLevel={state.assistanceLevel}
|
||||
selectedMap={state.selectedMap}
|
||||
selectedContinent={state.selectedContinent}
|
||||
onRegionClick={clickRegion}
|
||||
guessHistory={state.guessHistory}
|
||||
playerMetadata={state.playerMetadata}
|
||||
giveUpReveal={state.giveUpReveal}
|
||||
hintActive={state.hintActive ?? null}
|
||||
onGiveUp={giveUp}
|
||||
gameMode={state.gameMode}
|
||||
currentPlayer={state.currentPlayer}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,47 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
import { css } from '@styled/css'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useKnowYourWorld } from '../Provider'
|
||||
import { DrillDownMapSelector } from './DrillDownMapSelector'
|
||||
import { WORLD_MAP, USA_MAP, DEFAULT_DIFFICULTY_CONFIG } from '../maps'
|
||||
import {
|
||||
ALL_REGION_SIZES,
|
||||
ASSISTANCE_LEVELS,
|
||||
getFilteredMapDataBySizesSync,
|
||||
REGION_SIZE_CONFIG,
|
||||
} from '../maps'
|
||||
import type { RegionSize, AssistanceLevelConfig } from '../maps'
|
||||
import type { ContinentId } from '../continents'
|
||||
|
||||
// Get term for regions based on map type
|
||||
function getRegionTerm(selectedMap: 'world' | 'usa'): string {
|
||||
return selectedMap === 'world' ? 'countries' : 'states'
|
||||
}
|
||||
|
||||
// Generate feature badges for an assistance level
|
||||
function getFeatureBadges(level: AssistanceLevelConfig): Array<{ label: string; icon: string }> {
|
||||
const badges: Array<{ label: string; icon: string }> = []
|
||||
|
||||
if (level.hotColdEnabled) {
|
||||
badges.push({ label: 'Hot/cold', icon: '🔥' })
|
||||
}
|
||||
|
||||
if (level.hintsMode === 'onRequest') {
|
||||
if (level.autoHintDefault) {
|
||||
badges.push({ label: 'Auto-hints', icon: '💡' })
|
||||
} else {
|
||||
badges.push({ label: 'Hints', icon: '💡' })
|
||||
}
|
||||
} else if (level.hintsMode === 'limited' && level.hintLimit) {
|
||||
badges.push({ label: `${level.hintLimit} hints`, icon: '💡' })
|
||||
}
|
||||
|
||||
return badges
|
||||
}
|
||||
|
||||
// Game mode options with rich descriptions
|
||||
const GAME_MODE_OPTIONS = [
|
||||
{
|
||||
|
|
@ -62,12 +95,42 @@ const STUDY_TIME_OPTIONS = [
|
|||
export function SetupPhase() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { state, startGame, setMap, setMode, setDifficulty, setStudyDuration, setContinent } =
|
||||
useKnowYourWorld()
|
||||
const {
|
||||
state,
|
||||
startGame,
|
||||
setMap,
|
||||
setMode,
|
||||
setRegionSizes,
|
||||
setAssistanceLevel,
|
||||
setStudyDuration,
|
||||
setContinent,
|
||||
} = useKnowYourWorld()
|
||||
|
||||
// Get difficulty config for current map
|
||||
const mapData = state.selectedMap === 'world' ? WORLD_MAP : USA_MAP
|
||||
const difficultyConfig = mapData.difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
|
||||
// Calculate region counts per size category
|
||||
const regionCountsBySize = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const size of ALL_REGION_SIZES) {
|
||||
try {
|
||||
const filteredData = getFilteredMapDataBySizesSync(
|
||||
state.selectedMap,
|
||||
state.selectedContinent,
|
||||
[size]
|
||||
)
|
||||
counts[size] = filteredData.regions.length
|
||||
} catch {
|
||||
counts[size] = 0
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [state.selectedMap, state.selectedContinent])
|
||||
|
||||
// Calculate the total region count for current selection
|
||||
const totalRegionCount = useMemo(() => {
|
||||
return state.includeSizes.reduce((sum, size) => sum + (regionCountsBySize[size] || 0), 0)
|
||||
}, [state.includeSizes, regionCountsBySize])
|
||||
|
||||
// Get the term for regions (countries/states)
|
||||
const regionTerm = getRegionTerm(state.selectedMap)
|
||||
|
||||
// Handle selection change from drill-down selector
|
||||
const handleSelectionChange = useCallback(
|
||||
|
|
@ -81,7 +144,21 @@ export function SetupPhase() {
|
|||
// Get selected options for display
|
||||
const selectedMode = GAME_MODE_OPTIONS.find((opt) => opt.value === state.gameMode)
|
||||
const selectedStudyTime = STUDY_TIME_OPTIONS.find((opt) => opt.value === state.studyDuration)
|
||||
const selectedDifficulty = difficultyConfig.levels.find((level) => level.id === state.difficulty)
|
||||
const selectedAssistance = ASSISTANCE_LEVELS.find((level) => level.id === state.assistanceLevel)
|
||||
|
||||
// Handle toggling a region size
|
||||
const toggleRegionSize = useCallback(
|
||||
(size: RegionSize) => {
|
||||
if (state.includeSizes.includes(size)) {
|
||||
// Don't allow removing the last size
|
||||
if (state.includeSizes.length === 1) return
|
||||
setRegionSizes(state.includeSizes.filter((s) => s !== size))
|
||||
} else {
|
||||
setRegionSizes([...state.includeSizes, size])
|
||||
}
|
||||
},
|
||||
[state.includeSizes, setRegionSizes]
|
||||
)
|
||||
|
||||
// Styles for Radix Select components
|
||||
const triggerStyles = css({
|
||||
|
|
@ -192,6 +269,7 @@ export function SetupPhase() {
|
|||
selectedContinent={state.selectedContinent}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onStartGame={startGame}
|
||||
includeSizes={state.includeSizes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -281,48 +359,78 @@ export function SetupPhase() {
|
|||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{/* Difficulty (only show if multiple levels) */}
|
||||
{difficultyConfig.levels.length > 1 && (
|
||||
<div
|
||||
data-setting="difficulty"
|
||||
className={css({ display: 'flex', flexDirection: 'column' })}
|
||||
{/* Assistance Level */}
|
||||
<div
|
||||
data-setting="assistance-level"
|
||||
className={css({ display: 'flex', flexDirection: 'column' })}
|
||||
>
|
||||
<label className={labelStyles}>Assistance</label>
|
||||
<Select.Root
|
||||
value={state.assistanceLevel}
|
||||
onValueChange={(value) =>
|
||||
setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none')
|
||||
}
|
||||
>
|
||||
<label className={labelStyles}>Difficulty</label>
|
||||
<Select.Root value={state.difficulty} onValueChange={setDifficulty}>
|
||||
<Select.Trigger className={triggerStyles}>
|
||||
<span className={css({ fontSize: '2xl' })}>
|
||||
{selectedDifficulty?.emoji || '🎯'}
|
||||
</span>
|
||||
<div className={css({ flex: 1, textAlign: 'left' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{selectedDifficulty?.label}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
{selectedDifficulty?.description || 'Select difficulty level'}
|
||||
</div>
|
||||
<Select.Trigger className={triggerStyles}>
|
||||
<span className={css({ fontSize: '2xl' })}>{selectedAssistance?.emoji || '💡'}</span>
|
||||
<div className={css({ flex: 1, textAlign: 'left' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{selectedAssistance?.label}
|
||||
</div>
|
||||
<Select.Icon className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>
|
||||
▼
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
|
||||
<Select.Viewport>
|
||||
{difficultyConfig.levels.map((level) => (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
{selectedAssistance?.description}
|
||||
</div>
|
||||
{/* Feature badges */}
|
||||
{selectedAssistance && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1',
|
||||
marginTop: '1',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{getFeatureBadges(selectedAssistance).map((badge) => (
|
||||
<span
|
||||
key={badge.label}
|
||||
className={css({
|
||||
fontSize: '2xs',
|
||||
padding: '0.5 1',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
>
|
||||
{badge.icon} {badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Select.Icon className={css({ color: isDark ? 'gray.400' : 'gray.500' })}>
|
||||
▼
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className={contentStyles} position="popper" sideOffset={5}>
|
||||
<Select.Viewport>
|
||||
{ASSISTANCE_LEVELS.map((level) => {
|
||||
const badges = getFeatureBadges(level)
|
||||
return (
|
||||
<Select.Item key={level.id} value={level.id} className={itemStyles}>
|
||||
<span className={css({ fontSize: '2xl' })}>{level.emoji || '🎯'}</span>
|
||||
<span className={css({ fontSize: '2xl' })}>{level.emoji}</span>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<Select.ItemText>
|
||||
<span
|
||||
|
|
@ -342,17 +450,41 @@ export function SetupPhase() {
|
|||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
{level.description || `${level.label} difficulty`}
|
||||
{level.description}
|
||||
</div>
|
||||
{/* Feature badges */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1',
|
||||
marginTop: '1',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.label}
|
||||
className={css({
|
||||
fontSize: '2xs',
|
||||
padding: '0.5 1',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
>
|
||||
{badge.icon} {badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{/* Study Duration */}
|
||||
<div
|
||||
|
|
@ -431,6 +563,146 @@ export function SetupPhase() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region Types Selection */}
|
||||
<div
|
||||
data-section="region-sizes"
|
||||
className={css({
|
||||
padding: '5',
|
||||
bg: isDark ? 'gray.800/50' : 'gray.50',
|
||||
rounded: '2xl',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '4',
|
||||
})}
|
||||
>
|
||||
<label className={labelStyles} style={{ marginBottom: 0 }}>
|
||||
Region Types
|
||||
</label>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'blue.300' : 'blue.600',
|
||||
})}
|
||||
>
|
||||
{totalRegionCount} {regionTerm} selected
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
{ALL_REGION_SIZES.map((size) => {
|
||||
const config = REGION_SIZE_CONFIG[size]
|
||||
const isChecked = state.includeSizes.includes(size)
|
||||
const isOnlyOne = state.includeSizes.length === 1 && isChecked
|
||||
const count = regionCountsBySize[size] || 0
|
||||
|
||||
return (
|
||||
<Checkbox.Root
|
||||
key={size}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRegionSize(size)}
|
||||
disabled={isOnlyOne}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
paddingX: '3',
|
||||
paddingY: '2',
|
||||
bg: isChecked
|
||||
? isDark
|
||||
? 'blue.800'
|
||||
: 'blue.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isChecked
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
: 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
rounded: 'full',
|
||||
cursor: isOnlyOne ? 'not-allowed' : 'pointer',
|
||||
opacity: isOnlyOne ? 0.5 : 1,
|
||||
transition: 'all 0.15s',
|
||||
_hover: isOnlyOne
|
||||
? {}
|
||||
: {
|
||||
bg: isChecked
|
||||
? isDark
|
||||
? 'blue.700'
|
||||
: 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.100',
|
||||
},
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'base' })}>{config.emoji}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: '500',
|
||||
color: isChecked
|
||||
? 'white'
|
||||
: isDark
|
||||
? 'gray.200'
|
||||
: 'gray.700',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontWeight: '600',
|
||||
fontSize: 'xs',
|
||||
color: isChecked
|
||||
? isDark
|
||||
? 'blue.200'
|
||||
: 'blue.100'
|
||||
: isDark
|
||||
? 'gray.400'
|
||||
: 'gray.500',
|
||||
bg: isChecked
|
||||
? isDark
|
||||
? 'blue.700'
|
||||
: 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
paddingX: '1.5',
|
||||
paddingY: '0.5',
|
||||
rounded: 'full',
|
||||
minWidth: '6',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</Checkbox.Root>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips Section */}
|
||||
<div
|
||||
data-element="tips"
|
||||
|
|
@ -443,10 +715,12 @@ export function SetupPhase() {
|
|||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<strong>Tip:</strong> Press G or click "Give Up" to skip a region you don't know.{' '}
|
||||
{state.difficulty === 'easy'
|
||||
? 'On Easy, skipped regions are re-asked after a few turns.'
|
||||
: 'On Hard, skipped regions are re-asked at the end.'}
|
||||
<strong>Tip:</strong> Press G to give up on a region.{' '}
|
||||
{(state.assistanceLevel === 'guided' || state.assistanceLevel === 'helpful') &&
|
||||
'Skipped regions return after 2-3 turns.'}
|
||||
{state.assistanceLevel === 'standard' && 'Giving up counts against your score.'}
|
||||
{state.assistanceLevel === 'none' && 'No assistance available in this mode.'}
|
||||
{selectedAssistance?.hintsMode === 'onRequest' && ' Press H for hints!'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ Choose from multiple maps (World, USA States) and difficulty levels!`,
|
|||
const defaultConfig: KnowYourWorldConfig = {
|
||||
selectedMap: 'world',
|
||||
gameMode: 'cooperative',
|
||||
difficulty: 'medium',
|
||||
includeSizes: ['huge', 'large', 'medium'],
|
||||
assistanceLevel: 'helpful',
|
||||
studyDuration: 0,
|
||||
selectedContinent: 'all',
|
||||
}
|
||||
|
|
@ -47,19 +48,26 @@ function validateKnowYourWorldConfig(config: unknown): config is KnowYourWorldCo
|
|||
'antarctica',
|
||||
]
|
||||
|
||||
const validSizes = ['huge', 'large', 'medium', 'small', 'tiny']
|
||||
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
|
||||
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'selectedMap' in config &&
|
||||
'gameMode' in config &&
|
||||
'difficulty' in config &&
|
||||
'includeSizes' in config &&
|
||||
'assistanceLevel' in config &&
|
||||
'studyDuration' in config &&
|
||||
'selectedContinent' in config &&
|
||||
(config.selectedMap === 'world' || config.selectedMap === 'usa') &&
|
||||
(config.gameMode === 'cooperative' ||
|
||||
config.gameMode === 'race' ||
|
||||
config.gameMode === 'turn-based') &&
|
||||
typeof config.difficulty === 'string' &&
|
||||
Array.isArray(config.includeSizes) &&
|
||||
config.includeSizes.every((s: unknown) => typeof s === 'string' && validSizes.includes(s)) &&
|
||||
typeof config.assistanceLevel === 'string' &&
|
||||
validAssistanceLevels.includes(config.assistanceLevel) &&
|
||||
(config.studyDuration === 0 ||
|
||||
config.studyDuration === 30 ||
|
||||
config.studyDuration === 60 ||
|
||||
|
|
|
|||
|
|
@ -87,71 +87,740 @@ if (typeof window !== 'undefined') {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Region size category for difficulty-based filtering
|
||||
*/
|
||||
export type RegionSize = 'huge' | 'large' | 'medium' | 'small' | 'tiny'
|
||||
|
||||
/**
|
||||
* Hints mode for difficulty levels
|
||||
*/
|
||||
export type HintsMode = 'onRequest' | 'limited' | 'none'
|
||||
|
||||
/**
|
||||
* Give up behavior mode
|
||||
*/
|
||||
export type GiveUpMode = 'reaskSoon' | 'reaskEnd' | 'countsAgainst' | 'skipEntirely'
|
||||
|
||||
/**
|
||||
* Difficulty level configuration for a map
|
||||
*/
|
||||
export interface DifficultyLevel {
|
||||
id: string // e.g., 'easy', 'medium', 'hard', 'standard'
|
||||
id: string // e.g., 'learning', 'easy', 'normal', 'expert'
|
||||
label: string // Display name
|
||||
emoji?: string // Optional emoji
|
||||
description?: string // Optional description for UI
|
||||
// Filtering: either explicit exclusions OR percentage
|
||||
description?: string // Short description for UI
|
||||
detailedDescription?: string // Longer description for tooltip/modal
|
||||
|
||||
// Region filtering (new size-based system)
|
||||
includeSizes?: RegionSize[] // Which size categories to include
|
||||
|
||||
// Legacy filtering (for backwards compatibility)
|
||||
excludeRegions?: string[] // Explicit region IDs to exclude
|
||||
keepPercentile?: number // 0-1, keep this % of largest regions (default 1.0)
|
||||
|
||||
// Feature flags
|
||||
hotColdEnabled?: boolean // Hot/cold feedback when clicking wrong regions
|
||||
hintsMode?: HintsMode // How hints work
|
||||
hintLimit?: number // For 'limited' mode, how many hints per game
|
||||
autoHintDefault?: boolean // Default state of auto-hint checkbox
|
||||
struggleHintEnabled?: boolean // Show hint after struggling (timer-based)
|
||||
giveUpMode?: GiveUpMode // What happens when player gives up
|
||||
wrongClickShowsName?: boolean // Show "That was [name]" vs just "Wrong!"
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-map difficulty configuration
|
||||
* @deprecated Use AssistanceLevelConfig instead - difficulty conflated region filtering with assistance
|
||||
*/
|
||||
export interface MapDifficultyConfig {
|
||||
levels: DifficultyLevel[]
|
||||
defaultLevel: string // ID of default level
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistance level configuration - controls gameplay features separate from region filtering
|
||||
*/
|
||||
export interface AssistanceLevelConfig {
|
||||
id: 'guided' | 'helpful' | 'standard' | 'none'
|
||||
label: string
|
||||
emoji: string
|
||||
description: string
|
||||
// Feature flags
|
||||
hotColdEnabled: boolean
|
||||
hintsMode: HintsMode
|
||||
hintLimit?: number // For 'limited' mode
|
||||
autoHintDefault: boolean
|
||||
struggleHintEnabled: boolean
|
||||
giveUpMode: GiveUpMode
|
||||
wrongClickShowsName: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistance levels - separate from region filtering
|
||||
*/
|
||||
export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
|
||||
{
|
||||
id: 'guided',
|
||||
label: 'Guided',
|
||||
emoji: '🎓',
|
||||
description: 'Maximum help - hot/cold feedback, auto-hints, shows names on wrong clicks',
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: true,
|
||||
struggleHintEnabled: true,
|
||||
giveUpMode: 'reaskSoon',
|
||||
wrongClickShowsName: true,
|
||||
},
|
||||
{
|
||||
id: 'helpful',
|
||||
label: 'Helpful',
|
||||
emoji: '💡',
|
||||
description: 'Hot/cold feedback and hints available on request',
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'reaskEnd',
|
||||
wrongClickShowsName: true,
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
label: 'Standard',
|
||||
emoji: '🎯',
|
||||
description: 'Limited hints (3), no hot/cold feedback',
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'limited',
|
||||
hintLimit: 3,
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'countsAgainst',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
label: 'No Assistance',
|
||||
emoji: '🏆',
|
||||
description: 'Pure challenge - no hints or feedback',
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'none',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'skipEntirely',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get assistance level config by ID
|
||||
*/
|
||||
export function getAssistanceLevel(id: string): AssistanceLevelConfig {
|
||||
return ASSISTANCE_LEVELS.find((l) => l.id === id) || ASSISTANCE_LEVELS[1] // Default to 'helpful'
|
||||
}
|
||||
|
||||
/**
|
||||
* Default region sizes to include (all sizes = complete set)
|
||||
*/
|
||||
export const ALL_REGION_SIZES: RegionSize[] = ['huge', 'large', 'medium', 'small', 'tiny']
|
||||
|
||||
/**
|
||||
* Display configuration for each region size
|
||||
*/
|
||||
export const REGION_SIZE_CONFIG: Record<RegionSize, { label: string; emoji: string; description: string }> = {
|
||||
huge: {
|
||||
label: 'Major',
|
||||
emoji: '🌍',
|
||||
description: 'Large, well-known countries/states',
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
emoji: '🗺️',
|
||||
description: 'Large territories',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
emoji: '📍',
|
||||
description: 'Mid-sized territories',
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
emoji: '🏝️',
|
||||
description: 'Small territories and islands',
|
||||
},
|
||||
tiny: {
|
||||
label: 'Tiny',
|
||||
emoji: '🔍',
|
||||
description: 'Microstates and remote territories',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Default assistance level
|
||||
*/
|
||||
export const DEFAULT_ASSISTANCE_LEVEL = 'helpful'
|
||||
|
||||
/**
|
||||
* Default region sizes (medium difficulty - most regions)
|
||||
*/
|
||||
export const DEFAULT_REGION_SIZES: RegionSize[] = ['huge', 'large', 'medium']
|
||||
|
||||
/**
|
||||
* Global default difficulty config for maps without custom config
|
||||
* @deprecated - kept for backwards compatibility, use ASSISTANCE_LEVELS instead
|
||||
* New 4-level system: Learning, Easy, Normal, Expert
|
||||
*/
|
||||
export const DEFAULT_DIFFICULTY_CONFIG: MapDifficultyConfig = {
|
||||
levels: [
|
||||
{
|
||||
id: 'learning',
|
||||
label: 'Learning',
|
||||
emoji: '🌱',
|
||||
description: 'Guided exploration with maximum help',
|
||||
detailedDescription:
|
||||
'Large countries only (~57). Hot/cold feedback guides you. Hints auto-open and appear if stuck.',
|
||||
includeSizes: ['huge', 'large'],
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: true,
|
||||
struggleHintEnabled: true,
|
||||
giveUpMode: 'reaskSoon',
|
||||
wrongClickShowsName: true,
|
||||
},
|
||||
{
|
||||
id: 'easy',
|
||||
label: 'Easy',
|
||||
emoji: '😊',
|
||||
description: '85% largest regions',
|
||||
keepPercentile: 0.85,
|
||||
description: 'Helpful feedback as you learn',
|
||||
detailedDescription: 'Most countries (~163). Hot/cold feedback. Press H for hints anytime.',
|
||||
includeSizes: ['huge', 'large', 'medium'],
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'reaskEnd',
|
||||
wrongClickShowsName: true,
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
label: 'Medium',
|
||||
emoji: '🙂',
|
||||
description: '98% largest regions',
|
||||
keepPercentile: 0.98,
|
||||
id: 'normal',
|
||||
label: 'Normal',
|
||||
emoji: '🎯',
|
||||
description: 'Standard challenge',
|
||||
detailedDescription: 'Nearly all countries (~223). No hot/cold. 3 hints per game.',
|
||||
includeSizes: ['huge', 'large', 'medium', 'small'],
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'limited',
|
||||
hintLimit: 3,
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'countsAgainst',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
{
|
||||
id: 'hard',
|
||||
label: 'Hard',
|
||||
emoji: '😰',
|
||||
description: 'All regions',
|
||||
keepPercentile: 1.0,
|
||||
id: 'expert',
|
||||
label: 'Expert',
|
||||
emoji: '🏆',
|
||||
description: 'Test your knowledge',
|
||||
detailedDescription: 'All countries including tiny islands (256). No hints. Pure geography.',
|
||||
includeSizes: ['huge', 'large', 'medium', 'small', 'tiny'],
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'none',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'skipEntirely',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
],
|
||||
defaultLevel: 'medium',
|
||||
defaultLevel: 'easy',
|
||||
}
|
||||
|
||||
/**
|
||||
* USA map difficulty config - single level, all regions
|
||||
* USA state size categories (by geographic area and recognition)
|
||||
* Uses state abbreviations (lowercase) from @svg-maps/usa
|
||||
*/
|
||||
const USA_STATE_SIZE_CATEGORIES: Record<RegionSize, Set<string>> = {
|
||||
// Huge: Instantly recognizable, largest states (~8)
|
||||
huge: new Set([
|
||||
'ca', // California
|
||||
'tx', // Texas
|
||||
'fl', // Florida
|
||||
'ny', // New York
|
||||
'ak', // Alaska
|
||||
'mt', // Montana
|
||||
'az', // Arizona
|
||||
'nv', // Nevada
|
||||
]),
|
||||
|
||||
// Large: Major states, clearly visible (~10)
|
||||
large: new Set([
|
||||
'nm', // New Mexico
|
||||
'co', // Colorado
|
||||
'or', // Oregon
|
||||
'wa', // Washington
|
||||
'ut', // Utah
|
||||
'wy', // Wyoming
|
||||
'mi', // Michigan
|
||||
'il', // Illinois
|
||||
'pa', // Pennsylvania
|
||||
'oh', // Ohio
|
||||
]),
|
||||
|
||||
// Medium: Recognizable states, moderate size (~17)
|
||||
medium: new Set([
|
||||
'id', // Idaho
|
||||
'nd', // North Dakota
|
||||
'sd', // South Dakota
|
||||
'ne', // Nebraska
|
||||
'ks', // Kansas
|
||||
'ok', // Oklahoma
|
||||
'mn', // Minnesota
|
||||
'ia', // Iowa
|
||||
'mo', // Missouri
|
||||
'ar', // Arkansas
|
||||
'la', // Louisiana
|
||||
'wi', // Wisconsin
|
||||
'in', // Indiana
|
||||
'ga', // Georgia
|
||||
'nc', // North Carolina
|
||||
'va', // Virginia
|
||||
'tn', // Tennessee
|
||||
]),
|
||||
|
||||
// Small: Smaller states (~10)
|
||||
small: new Set([
|
||||
'sc', // South Carolina
|
||||
'al', // Alabama
|
||||
'ms', // Mississippi
|
||||
'ky', // Kentucky
|
||||
'wv', // West Virginia
|
||||
'md', // Maryland
|
||||
'nj', // New Jersey
|
||||
'ma', // Massachusetts
|
||||
'me', // Maine
|
||||
'hi', // Hawaii
|
||||
]),
|
||||
|
||||
// Tiny: Small states, harder to find (~6)
|
||||
tiny: new Set([
|
||||
'vt', // Vermont
|
||||
'nh', // New Hampshire
|
||||
'ct', // Connecticut
|
||||
'ri', // Rhode Island
|
||||
'de', // Delaware
|
||||
'dc', // District of Columbia
|
||||
]),
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size category for a US state
|
||||
*/
|
||||
function getUSAStateSizeCategory(stateId: string): RegionSize | null {
|
||||
for (const [size, ids] of Object.entries(USA_STATE_SIZE_CATEGORIES)) {
|
||||
if (ids.has(stateId)) {
|
||||
return size as RegionSize
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a US state should be included based on difficulty level's size requirements
|
||||
*/
|
||||
function shouldIncludeUSAState(stateId: string, includeSizes: RegionSize[]): boolean {
|
||||
const category = getUSAStateSizeCategory(stateId)
|
||||
if (!category) {
|
||||
// If no category found, include by default
|
||||
return true
|
||||
}
|
||||
return includeSizes.includes(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* USA map difficulty config - 4 levels like world map
|
||||
*/
|
||||
export const USA_DIFFICULTY_CONFIG: MapDifficultyConfig = {
|
||||
levels: [
|
||||
{
|
||||
id: 'standard',
|
||||
label: 'All States',
|
||||
emoji: '🗺️',
|
||||
description: 'All 50 states + DC',
|
||||
keepPercentile: 1.0,
|
||||
id: 'learning',
|
||||
label: 'Learning',
|
||||
emoji: '🌱',
|
||||
description: 'Guided exploration with maximum help',
|
||||
detailedDescription:
|
||||
'Major states only (~18). Hot/cold feedback guides you. Hints auto-open and appear if stuck.',
|
||||
includeSizes: ['huge', 'large'],
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: true,
|
||||
struggleHintEnabled: true,
|
||||
wrongClickShowsName: true,
|
||||
giveUpMode: 'reaskSoon',
|
||||
},
|
||||
{
|
||||
id: 'easy',
|
||||
label: 'Easy',
|
||||
emoji: '😊',
|
||||
description: 'Comfortable challenge with guidance',
|
||||
detailedDescription: 'Most states (~35). Hot/cold feedback. Press H for hints anytime.',
|
||||
includeSizes: ['huge', 'large', 'medium'],
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
wrongClickShowsName: true,
|
||||
giveUpMode: 'reaskEnd',
|
||||
},
|
||||
{
|
||||
id: 'normal',
|
||||
label: 'Normal',
|
||||
emoji: '🎯',
|
||||
description: 'Standard challenge with limited hints',
|
||||
detailedDescription: 'Nearly all states (~45). No hot/cold. 3 hints per game.',
|
||||
includeSizes: ['huge', 'large', 'medium', 'small'],
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'limited',
|
||||
hintLimit: 3,
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'countsAgainst',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
{
|
||||
id: 'expert',
|
||||
label: 'Expert',
|
||||
emoji: '🏆',
|
||||
description: 'Full challenge, no assistance',
|
||||
detailedDescription: 'All 51 states/territories. No hints. Pure geography.',
|
||||
includeSizes: ['huge', 'large', 'medium', 'small', 'tiny'],
|
||||
hotColdEnabled: false,
|
||||
hintsMode: 'none',
|
||||
autoHintDefault: false,
|
||||
struggleHintEnabled: false,
|
||||
giveUpMode: 'skipEntirely',
|
||||
wrongClickShowsName: false,
|
||||
},
|
||||
],
|
||||
defaultLevel: 'standard',
|
||||
defaultLevel: 'easy',
|
||||
}
|
||||
|
||||
/**
|
||||
* Region size categories for world map
|
||||
* Curated based on actual geographic size and prominence (not bounding box area)
|
||||
* ISO 3166-1 alpha-2 codes (lowercase)
|
||||
*/
|
||||
const REGION_SIZE_CATEGORIES: Record<RegionSize, Set<string>> = {
|
||||
// Huge: Major powers, instantly recognizable (~15)
|
||||
huge: new Set([
|
||||
'ru', // Russia
|
||||
'cn', // China
|
||||
'us', // United States
|
||||
'ca', // Canada
|
||||
'br', // Brazil
|
||||
'au', // Australia
|
||||
'in', // India
|
||||
'ar', // Argentina
|
||||
'kz', // Kazakhstan
|
||||
'dz', // Algeria
|
||||
'cd', // DR Congo
|
||||
'sa', // Saudi Arabia
|
||||
'mx', // Mexico
|
||||
'id', // Indonesia
|
||||
'ly', // Libya
|
||||
]),
|
||||
|
||||
// Large: Major countries, clearly visible (~42)
|
||||
large: new Set([
|
||||
'sd',
|
||||
'ir',
|
||||
'mn',
|
||||
'pe',
|
||||
'td',
|
||||
'ne',
|
||||
'ao',
|
||||
'ml',
|
||||
'za',
|
||||
'co',
|
||||
've',
|
||||
'et',
|
||||
'eg',
|
||||
'mr',
|
||||
'bo',
|
||||
'ng',
|
||||
'tz',
|
||||
'cl',
|
||||
'zm',
|
||||
'mm',
|
||||
'af',
|
||||
'so',
|
||||
'cf',
|
||||
'ss',
|
||||
'mg',
|
||||
'mz',
|
||||
'pk',
|
||||
'tr',
|
||||
'ke',
|
||||
'fr',
|
||||
'th',
|
||||
'es',
|
||||
'cm',
|
||||
'pg',
|
||||
'ma',
|
||||
'ua',
|
||||
'jp',
|
||||
'de',
|
||||
'pl',
|
||||
'no',
|
||||
'se',
|
||||
'fi',
|
||||
]),
|
||||
|
||||
// Medium: Recognizable countries, moderate size (~106)
|
||||
medium: new Set([
|
||||
// Europe
|
||||
'gb',
|
||||
'it',
|
||||
'ro',
|
||||
'gr',
|
||||
'bg',
|
||||
'hu',
|
||||
'by',
|
||||
'at',
|
||||
'cz',
|
||||
'rs',
|
||||
'ie',
|
||||
'lt',
|
||||
'lv',
|
||||
'hr',
|
||||
'ba',
|
||||
'sk',
|
||||
'ee',
|
||||
'dk',
|
||||
'nl',
|
||||
'be',
|
||||
'ch',
|
||||
'pt',
|
||||
'al',
|
||||
'md',
|
||||
'mk',
|
||||
'si',
|
||||
'me',
|
||||
'xk',
|
||||
'is',
|
||||
// Asia
|
||||
'vn',
|
||||
'my',
|
||||
'ph',
|
||||
'np',
|
||||
'bd',
|
||||
'kh',
|
||||
'la',
|
||||
'kp',
|
||||
'kr',
|
||||
'tw',
|
||||
'uz',
|
||||
'tm',
|
||||
'kg',
|
||||
'tj',
|
||||
'iq',
|
||||
'sy',
|
||||
'jo',
|
||||
'il',
|
||||
'lb',
|
||||
'az',
|
||||
'ge',
|
||||
'am',
|
||||
'ye',
|
||||
'om',
|
||||
'ae',
|
||||
'bt',
|
||||
'ps',
|
||||
'tl',
|
||||
// Africa
|
||||
'ci',
|
||||
'bf',
|
||||
'gh',
|
||||
'gn',
|
||||
'sn',
|
||||
'ug',
|
||||
'ga',
|
||||
'tg',
|
||||
'bj',
|
||||
'er',
|
||||
'mw',
|
||||
'ls',
|
||||
'sz',
|
||||
'rw',
|
||||
'bi',
|
||||
'sl',
|
||||
'lr',
|
||||
'gm',
|
||||
'gw',
|
||||
'cg',
|
||||
'gq',
|
||||
'dj',
|
||||
'tn',
|
||||
'bw',
|
||||
'na',
|
||||
'zw',
|
||||
'eh',
|
||||
// Americas
|
||||
'ec',
|
||||
'py',
|
||||
'uy',
|
||||
'sr',
|
||||
'gy',
|
||||
'pa',
|
||||
'cr',
|
||||
'ni',
|
||||
'hn',
|
||||
'gt',
|
||||
'bz',
|
||||
'sv',
|
||||
'cu',
|
||||
'do',
|
||||
'ht',
|
||||
'jm',
|
||||
'bs',
|
||||
'tt',
|
||||
'gf',
|
||||
'gl',
|
||||
// Oceania
|
||||
'nz',
|
||||
'fj',
|
||||
]),
|
||||
|
||||
// Small: Smaller countries, harder to find (~60)
|
||||
small: new Set([
|
||||
// Caribbean
|
||||
'bb',
|
||||
'ag',
|
||||
'dm',
|
||||
'lc',
|
||||
'vc',
|
||||
'gd',
|
||||
'kn',
|
||||
'aw',
|
||||
'cw',
|
||||
'bq',
|
||||
'sx',
|
||||
'mf',
|
||||
'bl',
|
||||
'tc',
|
||||
'vg',
|
||||
'vi',
|
||||
'ky',
|
||||
'ai',
|
||||
'ms',
|
||||
'pr',
|
||||
'bm',
|
||||
'gp',
|
||||
'mq',
|
||||
// Europe
|
||||
'lu',
|
||||
'cy',
|
||||
'mt',
|
||||
'ax',
|
||||
'fo',
|
||||
'gg',
|
||||
'im',
|
||||
'je',
|
||||
// Middle East
|
||||
'kw',
|
||||
'qa',
|
||||
'bh',
|
||||
// Africa
|
||||
'cv',
|
||||
'st',
|
||||
'km',
|
||||
'mu',
|
||||
're',
|
||||
'yt',
|
||||
'sc',
|
||||
'sh',
|
||||
// Asia
|
||||
'bn',
|
||||
'sg',
|
||||
'hk',
|
||||
'mo',
|
||||
'mv',
|
||||
'lk',
|
||||
'pm',
|
||||
// Oceania
|
||||
'ws',
|
||||
'to',
|
||||
'vu',
|
||||
'sb',
|
||||
'nc',
|
||||
'pf',
|
||||
'gu',
|
||||
'as',
|
||||
'mp',
|
||||
'pw',
|
||||
'fm',
|
||||
]),
|
||||
|
||||
// Tiny: Microstates and tiny islands, very hard to find (~33)
|
||||
tiny: new Set([
|
||||
// Europe
|
||||
'va',
|
||||
'mc',
|
||||
'sm',
|
||||
'ad',
|
||||
'li',
|
||||
'gi',
|
||||
// Pacific
|
||||
'nr',
|
||||
'tv',
|
||||
'mh',
|
||||
'ki',
|
||||
'nu',
|
||||
'tk',
|
||||
'ck',
|
||||
'wf',
|
||||
'pn',
|
||||
// Other territories
|
||||
'io',
|
||||
'cx',
|
||||
'cc',
|
||||
'nf',
|
||||
'hm',
|
||||
'bv',
|
||||
'sj',
|
||||
'fk',
|
||||
'gs',
|
||||
'aq',
|
||||
'tf',
|
||||
'go',
|
||||
'ju', // French territories
|
||||
'um-dq',
|
||||
'um-fq',
|
||||
'um-hq',
|
||||
'um-jq',
|
||||
'um-mq',
|
||||
'um-wq', // US Minor Outlying Islands
|
||||
]),
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size category for a region ID
|
||||
*/
|
||||
export function getRegionSizeCategory(regionId: string): RegionSize | null {
|
||||
for (const [size, ids] of Object.entries(REGION_SIZE_CATEGORIES)) {
|
||||
if (ids.has(regionId)) {
|
||||
return size as RegionSize
|
||||
}
|
||||
}
|
||||
return null // Region not categorized (shouldn't happen for world map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a region should be included based on difficulty level's size requirements
|
||||
*/
|
||||
export function shouldIncludeRegion(regionId: string, includeSizes: RegionSize[]): boolean {
|
||||
const category = getRegionSizeCategory(regionId)
|
||||
if (!category) {
|
||||
// If no category found, include by default (for regions not in our list)
|
||||
return true
|
||||
}
|
||||
return includeSizes.includes(category)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -686,7 +1355,6 @@ export function calculateContinentViewBox(
|
|||
// Check for custom crop override first
|
||||
const customCrop = getCustomCrop(mapId, continentId)
|
||||
if (customCrop) {
|
||||
console.log(`[Maps] Using custom crop for ${mapId}/${continentId}: ${customCrop}`)
|
||||
return customCrop
|
||||
}
|
||||
|
||||
|
|
@ -837,11 +1505,45 @@ function calculateRegionArea(pathString: string): number {
|
|||
}
|
||||
|
||||
/**
|
||||
* Filter regions based on difficulty level configuration
|
||||
* Supports both explicit region exclusions and percentile-based filtering
|
||||
* Filter regions by size categories (primary filtering function)
|
||||
* This is the new clean API that takes sizes directly
|
||||
*/
|
||||
function filterRegionsByDifficulty(regions: MapRegion[], level: DifficultyLevel): MapRegion[] {
|
||||
// Explicit exclusions take priority
|
||||
export function filterRegionsBySizes(
|
||||
regions: MapRegion[],
|
||||
includeSizes: RegionSize[],
|
||||
mapId: 'world' | 'usa' = 'world'
|
||||
): MapRegion[] {
|
||||
// If all sizes included or empty array, return all regions
|
||||
if (includeSizes.length === 0 || includeSizes.length === ALL_REGION_SIZES.length) {
|
||||
return regions
|
||||
}
|
||||
|
||||
// Use appropriate size checker based on map
|
||||
const shouldInclude =
|
||||
mapId === 'usa'
|
||||
? (id: string) => shouldIncludeUSAState(id, includeSizes)
|
||||
: (id: string) => shouldIncludeRegion(id, includeSizes)
|
||||
|
||||
const filtered = regions.filter((r) => shouldInclude(r.id))
|
||||
return filtered
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter regions based on difficulty level configuration
|
||||
* @deprecated Use filterRegionsBySizes instead
|
||||
* Supports: 1) Size-based filtering (new), 2) Explicit exclusions, 3) Percentile-based (legacy)
|
||||
*/
|
||||
function filterRegionsByDifficulty(
|
||||
regions: MapRegion[],
|
||||
level: DifficultyLevel,
|
||||
mapId: 'world' | 'usa' = 'world'
|
||||
): MapRegion[] {
|
||||
// 1. Size-based filtering (new system - highest priority)
|
||||
if (level.includeSizes && level.includeSizes.length > 0) {
|
||||
return filterRegionsBySizes(regions, level.includeSizes, mapId)
|
||||
}
|
||||
|
||||
// 2. Explicit exclusions
|
||||
if (level.excludeRegions && level.excludeRegions.length > 0) {
|
||||
const filtered = regions.filter((r) => !level.excludeRegions!.includes(r.id))
|
||||
console.log(
|
||||
|
|
@ -850,7 +1552,7 @@ function filterRegionsByDifficulty(regions: MapRegion[], level: DifficultyLevel)
|
|||
return filtered
|
||||
}
|
||||
|
||||
// Use percentile filtering
|
||||
// 3. Legacy percentile filtering
|
||||
const percentile = level.keepPercentile ?? 1.0
|
||||
if (percentile >= 1.0) {
|
||||
return regions // Include all regions
|
||||
|
|
@ -933,7 +1635,7 @@ export async function getFilteredMapData(
|
|||
}
|
||||
|
||||
// Apply difficulty filtering
|
||||
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
|
||||
filteredRegions = filterRegionsByDifficulty(filteredRegions, level, mapId)
|
||||
|
||||
return {
|
||||
...mapData,
|
||||
|
|
@ -944,6 +1646,84 @@ export async function getFilteredMapData(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered map data by size categories (async - for server-side)
|
||||
* This is the new clean API that takes sizes directly
|
||||
*/
|
||||
export async function getFilteredMapDataBySizes(
|
||||
mapId: 'world' | 'usa',
|
||||
continentId: ContinentId | 'all',
|
||||
includeSizes: RegionSize[]
|
||||
): Promise<MapData> {
|
||||
const mapData = await getMapData(mapId)
|
||||
|
||||
let filteredRegions = mapData.regions
|
||||
let adjustedViewBox = mapData.viewBox
|
||||
let customCrop: string | null = null
|
||||
|
||||
// Apply continent filtering for world map
|
||||
if (mapId === 'world' && continentId !== 'all') {
|
||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||
customCrop = getCustomCrop(mapId, continentId)
|
||||
adjustedViewBox = calculateContinentViewBox(
|
||||
mapData.regions,
|
||||
continentId,
|
||||
mapData.viewBox,
|
||||
mapId
|
||||
)
|
||||
}
|
||||
|
||||
// Apply size filtering
|
||||
filteredRegions = filterRegionsBySizes(filteredRegions, includeSizes, mapId)
|
||||
|
||||
return {
|
||||
...mapData,
|
||||
regions: filteredRegions,
|
||||
viewBox: adjustedViewBox,
|
||||
originalViewBox: mapData.viewBox,
|
||||
customCrop,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered map data by size categories synchronously (for client components)
|
||||
* This is the new clean API that takes sizes directly
|
||||
*/
|
||||
export function getFilteredMapDataBySizesSync(
|
||||
mapId: 'world' | 'usa',
|
||||
continentId: ContinentId | 'all',
|
||||
includeSizes: RegionSize[]
|
||||
): MapData {
|
||||
const mapData = mapId === 'world' ? WORLD_MAP : USA_MAP
|
||||
|
||||
let filteredRegions = mapData.regions
|
||||
let adjustedViewBox = mapData.viewBox
|
||||
let customCrop: string | null = null
|
||||
|
||||
// Apply continent filtering for world map
|
||||
if (mapId === 'world' && continentId !== 'all') {
|
||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||
customCrop = getCustomCrop(mapId, continentId)
|
||||
adjustedViewBox = calculateContinentViewBox(
|
||||
mapData.regions,
|
||||
continentId,
|
||||
mapData.viewBox,
|
||||
mapId
|
||||
)
|
||||
}
|
||||
|
||||
// Apply size filtering
|
||||
filteredRegions = filterRegionsBySizes(filteredRegions, includeSizes, mapId)
|
||||
|
||||
return {
|
||||
...mapData,
|
||||
regions: filteredRegions,
|
||||
viewBox: adjustedViewBox,
|
||||
originalViewBox: mapData.viewBox,
|
||||
customCrop,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-map registry: maps country/region IDs to their detailed sub-maps
|
||||
* For drill-down navigation in the map selector
|
||||
|
|
@ -1060,7 +1840,7 @@ export function getFilteredMapDataSync(
|
|||
}
|
||||
|
||||
// Apply difficulty filtering
|
||||
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
|
||||
filteredRegions = filterRegionsByDifficulty(filteredRegions, level, mapId)
|
||||
|
||||
return {
|
||||
...mapData,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
import type { ContinentId } from './continents'
|
||||
import type { MapDifficultyConfig } from './maps'
|
||||
import type { MapDifficultyConfig, RegionSize } from './maps'
|
||||
|
||||
/**
|
||||
* Assistance level - controls gameplay features (hints, hot/cold, etc.)
|
||||
* Separate from region filtering
|
||||
*/
|
||||
export type AssistanceLevel = 'guided' | 'helpful' | 'standard' | 'none'
|
||||
|
||||
// Game configuration (persisted to database)
|
||||
export interface KnowYourWorldConfig extends GameConfig {
|
||||
selectedMap: 'world' | 'usa'
|
||||
gameMode: 'cooperative' | 'race' | 'turn-based'
|
||||
difficulty: string // Difficulty level ID (e.g., 'easy', 'medium', 'hard', 'standard')
|
||||
// Region filtering (which regions to include by size)
|
||||
includeSizes: RegionSize[] // e.g., ['huge', 'large'] for major regions only
|
||||
// Assistance level (gameplay features)
|
||||
assistanceLevel: AssistanceLevel
|
||||
// Legacy field - kept for backwards compatibility
|
||||
difficulty?: string // @deprecated Use includeSizes + assistanceLevel instead
|
||||
studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode)
|
||||
selectedContinent: ContinentId | 'all' // continent filter for world map ('all' = no filter)
|
||||
}
|
||||
|
|
@ -46,7 +57,12 @@ export interface KnowYourWorldState extends GameState {
|
|||
// Setup configuration
|
||||
selectedMap: 'world' | 'usa'
|
||||
gameMode: 'cooperative' | 'race' | 'turn-based'
|
||||
difficulty: string // Difficulty level ID (e.g., 'easy', 'medium', 'hard', 'standard')
|
||||
// Region filtering (which regions to include by size)
|
||||
includeSizes: RegionSize[] // e.g., ['huge', 'large'] for major regions only
|
||||
// Assistance level (gameplay features)
|
||||
assistanceLevel: AssistanceLevel
|
||||
// Legacy field - kept for backwards compatibility during migration
|
||||
difficulty?: string // @deprecated Use includeSizes + assistanceLevel instead
|
||||
studyDuration: 0 | 30 | 60 | 120 // seconds (0 = skip study mode)
|
||||
selectedContinent: ContinentId | 'all' // continent filter for world map ('all' = no filter)
|
||||
|
||||
|
|
@ -84,6 +100,13 @@ export interface KnowYourWorldState extends GameState {
|
|||
|
||||
// Unanimous give-up voting (for cooperative multiplayer)
|
||||
giveUpVotes: string[] // Session/viewer IDs (userIds) who have voted to give up on current prompt
|
||||
|
||||
// Hint system
|
||||
hintsUsed: number // Total hints used this game
|
||||
hintActive: {
|
||||
regionId: string
|
||||
timestamp: number // For animation timing
|
||||
} | null
|
||||
}
|
||||
|
||||
// Move types
|
||||
|
|
@ -98,7 +121,8 @@ export type KnowYourWorldMove =
|
|||
playerMetadata: Record<string, any>
|
||||
selectedMap: 'world' | 'usa'
|
||||
gameMode: 'cooperative' | 'race' | 'turn-based'
|
||||
difficulty: string // Difficulty level ID
|
||||
includeSizes: RegionSize[] // Which region sizes to include
|
||||
assistanceLevel: AssistanceLevel // Gameplay assistance level
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -144,12 +168,21 @@ export type KnowYourWorldMove =
|
|||
}
|
||||
}
|
||||
| {
|
||||
type: 'SET_DIFFICULTY'
|
||||
type: 'SET_REGION_SIZES'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
difficulty: string // Difficulty level ID
|
||||
includeSizes: RegionSize[] // Which region sizes to include
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'SET_ASSISTANCE_LEVEL'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
assistanceLevel: AssistanceLevel
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -191,3 +224,10 @@ export type KnowYourWorldMove =
|
|||
timestamp: number
|
||||
data: {}
|
||||
}
|
||||
| {
|
||||
type: 'REQUEST_HINT'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,10 +487,7 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
|
|||
foundGoodZoom = true
|
||||
acceptedRegionId = detectedRegion.id
|
||||
|
||||
// Log when we accept a zoom
|
||||
console.log(
|
||||
`[Zoom] ✅ Accepted ${testZoom.toFixed(1)}x for ${detectedRegion.id} (${currentWidth.toFixed(1)}px × ${currentHeight.toFixed(1)}px)`
|
||||
)
|
||||
// Zoom accepted (log removed to reduce spam)
|
||||
|
||||
// Mark this region's bounding box as accepted
|
||||
const acceptedBox = boundingBoxes.find((bbox) => bbox.regionId === detectedRegion.id)
|
||||
|
|
|
|||
|
|
@ -183,7 +183,8 @@ export const DEFAULT_YIJS_DEMO_CONFIG: YjsDemoGameConfig = {
|
|||
export const DEFAULT_KNOW_YOUR_WORLD_CONFIG: KnowYourWorldConfig = {
|
||||
selectedMap: 'world',
|
||||
gameMode: 'cooperative',
|
||||
difficulty: 'easy',
|
||||
includeSizes: ['huge', 'large', 'medium'],
|
||||
assistanceLevel: 'helpful',
|
||||
studyDuration: 0,
|
||||
selectedContinent: 'all',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue