feat(know-your-world): add Learning mode and fix hints before name unlock
- Add new "Learning" assistance level (📚) that requires typing first 3 letters before hints are shown - best for memorizing region names - Remove 3-letter requirement from "Guided" mode (now just has auto-hints) - Fix bug where auto-hints bypassed the name confirmation requirement - Add hintsLocked prop to MapRenderer to properly suppress hints - Share hint unlock state between GameInfoPanel and MapRenderer via PlayingPhase The Learning mode is now the easiest option, designed specifically for learning region names by requiring active recall before showing hints. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a17e951e7c
commit
fc87808b40
|
|
@ -38,7 +38,12 @@ interface KnowYourWorldContextValue {
|
|||
// Cursor position sharing (for multiplayer)
|
||||
otherPlayerCursors: Record<
|
||||
string,
|
||||
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
hoveredRegionId: string | null
|
||||
} | null
|
||||
>
|
||||
sendCursorUpdate: (
|
||||
playerId: string,
|
||||
|
|
@ -98,7 +103,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
|||
: ['huge', 'large', 'medium'] // Default to most regions
|
||||
|
||||
// Validate assistanceLevel
|
||||
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
|
||||
const validAssistanceLevels = ['learning', 'guided', 'helpful', 'standard', 'none']
|
||||
const rawAssistance = gameConfig?.assistanceLevel
|
||||
const assistanceLevel: AssistanceLevel =
|
||||
typeof rawAssistance === 'string' && validAssistanceLevels.includes(rawAssistance)
|
||||
|
|
|
|||
|
|
@ -127,7 +127,10 @@ export class KnowYourWorldValidator
|
|||
data: any
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only click regions during playing phase' }
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only click regions during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.currentPrompt) {
|
||||
|
|
@ -351,7 +354,10 @@ export class KnowYourWorldValidator
|
|||
includeSizes: RegionSize[]
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change region sizes during setup' }
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only change region sizes during setup',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
|
|
@ -367,7 +373,10 @@ export class KnowYourWorldValidator
|
|||
assistanceLevel: AssistanceLevel
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change assistance level during setup' }
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only change assistance level during setup',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: KnowYourWorldState = {
|
||||
|
|
@ -448,7 +457,10 @@ export class KnowYourWorldValidator
|
|||
// Check if this session has already voted
|
||||
const existingVotes = state.giveUpVotes ?? []
|
||||
if (existingVotes.includes(userId)) {
|
||||
return { valid: false, error: 'Your session has already voted to give up' }
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Your session has already voted to give up',
|
||||
}
|
||||
}
|
||||
|
||||
// Add this session's vote
|
||||
|
|
@ -498,10 +510,12 @@ export class KnowYourWorldValidator
|
|||
: [...existingGivenUp, region.id]
|
||||
|
||||
// Determine re-ask position based on assistance level
|
||||
// Guided/Helpful: re-ask soon (after 2-3 regions) to reinforce learning
|
||||
// Learning/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'
|
||||
state.assistanceLevel === 'learning' ||
|
||||
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
|
||||
|
|
@ -548,7 +562,10 @@ export class KnowYourWorldValidator
|
|||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Can only request hints during playing phase' }
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only request hints during playing phase',
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.currentPrompt) {
|
||||
|
|
@ -588,7 +605,13 @@ export class KnowYourWorldValidator
|
|||
: ['huge', 'large', 'medium'] // Default
|
||||
|
||||
// Validate assistanceLevel
|
||||
const validAssistanceLevels: AssistanceLevel[] = ['guided', 'helpful', 'standard', 'none']
|
||||
const validAssistanceLevels: AssistanceLevel[] = [
|
||||
'learning',
|
||||
'guided',
|
||||
'helpful',
|
||||
'standard',
|
||||
'none',
|
||||
]
|
||||
const rawAssistance = typedConfig?.assistanceLevel
|
||||
const assistanceLevel: AssistanceLevel = validAssistanceLevels.includes(
|
||||
rawAssistance as AssistanceLevel
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ interface GameInfoPanelProps {
|
|||
foundCount: number
|
||||
totalRegions: number
|
||||
progress: number
|
||||
/** Callback when hints are unlocked (after name confirmation) */
|
||||
onHintsUnlock?: () => void
|
||||
}
|
||||
|
||||
export function GameInfoPanel({
|
||||
|
|
@ -36,6 +38,7 @@ export function GameInfoPanel({
|
|||
foundCount,
|
||||
totalRegions,
|
||||
progress,
|
||||
onHintsUnlock,
|
||||
}: GameInfoPanelProps) {
|
||||
// Get flag emoji for world map countries (not USA states)
|
||||
const flagEmoji =
|
||||
|
|
@ -117,11 +120,12 @@ export function GameInfoPanel({
|
|||
if (requiresNameConfirmation > 0 && currentRegionName && nameInput.length > 0) {
|
||||
const requiredPart = currentRegionName.slice(0, requiresNameConfirmation).toLowerCase()
|
||||
const inputPart = nameInput.toLowerCase()
|
||||
if (inputPart === requiredPart) {
|
||||
if (inputPart === requiredPart && !nameConfirmed) {
|
||||
setNameConfirmed(true)
|
||||
onHintsUnlock?.()
|
||||
}
|
||||
}
|
||||
}, [nameInput, currentRegionName, requiresNameConfirmation])
|
||||
}, [nameInput, currentRegionName, requiresNameConfirmation, nameConfirmed, onHintsUnlock])
|
||||
|
||||
// Determine if hints are available based on difficulty config
|
||||
const hintsAvailable = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ interface MapRendererProps {
|
|||
mapData: MapData
|
||||
regionsFound: string[]
|
||||
currentPrompt: string | null
|
||||
assistanceLevel: 'guided' | 'helpful' | 'standard' | 'none' // Controls gameplay features (hints, hot/cold)
|
||||
assistanceLevel: 'learning' | '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
|
||||
|
|
@ -251,7 +251,12 @@ interface MapRendererProps {
|
|||
localPlayerId?: string // The local player's ID (to filter out our own cursor from others)
|
||||
otherPlayerCursors?: Record<
|
||||
string,
|
||||
{ x: number; y: number; userId: string; hoveredRegionId: string | null } | null
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
hoveredRegionId: string | null
|
||||
} | null
|
||||
>
|
||||
onCursorUpdate?: (
|
||||
cursorPosition: { x: number; y: number } | null,
|
||||
|
|
@ -263,6 +268,8 @@ interface MapRendererProps {
|
|||
viewerId?: string // This viewer's userId (to check if local session has voted)
|
||||
// Member players mapping (userId -> players) for cursor emoji display
|
||||
memberPlayers?: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
|
||||
/** When true, hints are locked (e.g., user hasn't typed required name confirmation yet) */
|
||||
hintsLocked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -328,6 +335,7 @@ export function MapRenderer({
|
|||
activeUserIds = [],
|
||||
viewerId,
|
||||
memberPlayers = {},
|
||||
hintsLocked = false,
|
||||
}: MapRendererProps) {
|
||||
// Extract force tuning parameters with defaults
|
||||
const {
|
||||
|
|
@ -440,8 +448,14 @@ export function MapRenderer({
|
|||
initialZoom: 10,
|
||||
})
|
||||
|
||||
const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 })
|
||||
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [svgDimensions, setSvgDimensions] = useState({
|
||||
width: 1000,
|
||||
height: 500,
|
||||
})
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
const [showMagnifier, setShowMagnifier] = useState(false)
|
||||
const [targetOpacity, setTargetOpacity] = useState(0)
|
||||
const [targetTop, setTargetTop] = useState(20)
|
||||
|
|
@ -754,12 +768,13 @@ export function MapRenderer({
|
|||
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
|
||||
// Also re-runs when hintsLocked changes (e.g., user unlocked hints by typing name)
|
||||
useEffect(() => {
|
||||
const isNewRegion = prevPromptRef.current !== null && prevPromptRef.current !== currentPrompt
|
||||
prevPromptRef.current = currentPrompt
|
||||
|
||||
if (autoHintRef.current && hasHint) {
|
||||
// Don't auto-show hints when locked (e.g., waiting for name confirmation)
|
||||
if (autoHintRef.current && hasHint && !hintsLocked) {
|
||||
setShowHintBubble(true)
|
||||
// If region changed and both auto-hint and auto-speak are enabled, speak immediately
|
||||
// This handles the case where the bubble was already open
|
||||
|
|
@ -769,7 +784,15 @@ export function MapRenderer({
|
|||
} else {
|
||||
setShowHintBubble(false)
|
||||
}
|
||||
}, [currentPrompt, hasHint, currentRegionName, hintText, isSpeechSupported, speakWithRegionName])
|
||||
}, [
|
||||
currentPrompt,
|
||||
hasHint,
|
||||
currentRegionName,
|
||||
hintText,
|
||||
isSpeechSupported,
|
||||
speakWithRegionName,
|
||||
hintsLocked,
|
||||
])
|
||||
|
||||
// Hot/cold audio feedback hook
|
||||
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
|
||||
|
|
@ -865,7 +888,10 @@ export function MapRenderer({
|
|||
if (pointerLocked && cursorPositionRef.current && containerRef.current && svgRef.current) {
|
||||
const { x: cursorX, y: cursorY } = cursorPositionRef.current
|
||||
|
||||
console.log('[CLICK] Pointer lock click at cursor position:', { cursorX, cursorY })
|
||||
console.log('[CLICK] Pointer lock click at cursor position:', {
|
||||
cursorX,
|
||||
cursorY,
|
||||
})
|
||||
|
||||
// Check if clicking on any registered button (Give Up, Hint, etc.)
|
||||
if (buttonRegistry.handleClick(cursorX, cursorY)) {
|
||||
|
|
@ -1127,7 +1153,11 @@ export function MapRenderer({
|
|||
})
|
||||
|
||||
// Start zoom-in animation using CSS transform
|
||||
console.log('[GiveUp Zoom] Setting zoom target:', { scale, translateX, translateY })
|
||||
console.log('[GiveUp Zoom] Setting zoom target:', {
|
||||
scale,
|
||||
translateX,
|
||||
translateY,
|
||||
})
|
||||
setGiveUpZoomTarget({ scale, translateX, translateY })
|
||||
}
|
||||
}
|
||||
|
|
@ -1666,8 +1696,13 @@ export function MapRenderer({
|
|||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
|
||||
const showOutline = (region: MapRegion): boolean => {
|
||||
// Guided/Helpful modes: always show outlines
|
||||
if (assistanceLevel === 'guided' || assistanceLevel === 'helpful') return true
|
||||
// Learning/Guided/Helpful modes: always show outlines
|
||||
if (
|
||||
assistanceLevel === 'learning' ||
|
||||
assistanceLevel === 'guided' ||
|
||||
assistanceLevel === 'helpful'
|
||||
)
|
||||
return true
|
||||
|
||||
// Standard/None modes: only show outline on hover or if found
|
||||
return hoveredRegion === region.id || regionsFound.includes(region.id)
|
||||
|
|
@ -1763,7 +1798,10 @@ export function MapRenderer({
|
|||
width: containerRect.width.toFixed(1),
|
||||
height: containerRect.height.toFixed(1),
|
||||
},
|
||||
svgSize: { width: svgRect.width.toFixed(1), height: svgRect.height.toFixed(1) },
|
||||
svgSize: {
|
||||
width: svgRect.width.toFixed(1),
|
||||
height: svgRect.height.toFixed(1),
|
||||
},
|
||||
svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) },
|
||||
distances: {
|
||||
left: dampenedDistLeft.toFixed(1),
|
||||
|
|
@ -3722,7 +3760,10 @@ export function MapRenderer({
|
|||
const magTL = { x: magLeft, y: magTop }
|
||||
const magTR = { x: magLeft + magnifierWidth, y: magTop }
|
||||
const magBL = { x: magLeft, y: magTop + magnifierHeight }
|
||||
const magBR = { x: magLeft + magnifierWidth, y: magTop + magnifierHeight }
|
||||
const magBR = {
|
||||
x: magLeft + magnifierWidth,
|
||||
y: magTop + magnifierHeight,
|
||||
}
|
||||
|
||||
// Check if a line segment passes through a rectangle (excluding endpoints)
|
||||
const linePassesThroughRect = (
|
||||
|
|
@ -3963,7 +4004,11 @@ export function MapRenderer({
|
|||
{zoomSearchDebugInfo && (
|
||||
<>
|
||||
<div
|
||||
style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #444' }}
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid #444',
|
||||
}}
|
||||
>
|
||||
<strong>Zoom Decision:</strong>
|
||||
</div>
|
||||
|
|
@ -4011,7 +4056,13 @@ export function MapRenderer({
|
|||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid #444' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid #444',
|
||||
}}
|
||||
>
|
||||
<strong>Detected Regions ({detectedRegions.length}):</strong>
|
||||
</div>
|
||||
{detectedRegions.map((region) => (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||
import { css } from '@styled/css'
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { useKnowYourWorld } from '../Provider'
|
||||
import { getFilteredMapDataBySizesSync } from '../maps'
|
||||
import { getFilteredMapDataBySizesSync, getAssistanceLevel } from '../maps'
|
||||
import { MapRenderer } from './MapRenderer'
|
||||
import { GameInfoPanel } from './GameInfoPanel'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
|
|
@ -55,6 +55,26 @@ export function PlayingPhase() {
|
|||
const currentRegionName = currentRegion?.name ?? null
|
||||
const currentRegionId = currentRegion?.id ?? null
|
||||
|
||||
// Check if hints are locked (name confirmation required but not yet done)
|
||||
const assistanceConfig = getAssistanceLevel(state.assistanceLevel)
|
||||
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
|
||||
|
||||
// Track whether hints have been unlocked for the current region
|
||||
const [hintsUnlocked, setHintsUnlocked] = useState(false)
|
||||
|
||||
// Reset hints locked state when region changes
|
||||
useEffect(() => {
|
||||
setHintsUnlocked(false)
|
||||
}, [state.currentPrompt])
|
||||
|
||||
// Hints are locked if name confirmation is required and not yet unlocked
|
||||
const hintsLocked = requiresNameConfirmation > 0 && !hintsUnlocked
|
||||
|
||||
// Callback for GameInfoPanel to notify when hints are unlocked
|
||||
const handleHintsUnlock = useCallback(() => {
|
||||
setHintsUnlocked(true)
|
||||
}, [])
|
||||
|
||||
// Error if prompt not found in filtered regions (indicates server/client filter mismatch)
|
||||
if (state.currentPrompt && !currentRegion) {
|
||||
const errorInfo = {
|
||||
|
|
@ -102,6 +122,7 @@ export function PlayingPhase() {
|
|||
foundCount={foundCount}
|
||||
totalRegions={totalRegions}
|
||||
progress={progress}
|
||||
onHintsUnlock={handleHintsUnlock}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
|
|
@ -164,6 +185,7 @@ export function PlayingPhase() {
|
|||
activeUserIds={state.activeUserIds}
|
||||
viewerId={viewerId ?? undefined}
|
||||
memberPlayers={memberPlayers}
|
||||
hintsLocked={hintsLocked}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ export function SetupPhase() {
|
|||
<Select.Root
|
||||
value={state.assistanceLevel}
|
||||
onValueChange={(value) =>
|
||||
setAssistanceLevel(value as 'guided' | 'helpful' | 'standard' | 'none')
|
||||
setAssistanceLevel(value as 'learning' | 'guided' | 'helpful' | 'standard' | 'none')
|
||||
}
|
||||
>
|
||||
<Select.Trigger className={cardTriggerStyles}>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function validateKnowYourWorldConfig(config: unknown): config is KnowYourWorldCo
|
|||
]
|
||||
|
||||
const validSizes = ['huge', 'large', 'medium', 'small', 'tiny']
|
||||
const validAssistanceLevels = ['guided', 'helpful', 'standard', 'none']
|
||||
const validAssistanceLevels = ['learning', 'guided', 'helpful', 'standard', 'none']
|
||||
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
|
|
|
|||
|
|
@ -163,11 +163,11 @@ export interface AssistanceLevelConfig {
|
|||
*/
|
||||
export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
|
||||
{
|
||||
id: 'guided',
|
||||
label: 'Guided',
|
||||
emoji: '🎓',
|
||||
id: 'learning',
|
||||
label: 'Learning',
|
||||
emoji: '📚',
|
||||
description:
|
||||
'Maximum help - type name to unlock hints, hot/cold feedback, shows names on wrong clicks',
|
||||
'Type first 3 letters to unlock hints, maximum feedback, best for memorizing names',
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: true,
|
||||
|
|
@ -176,6 +176,18 @@ export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
|
|||
wrongClickShowsName: true,
|
||||
nameConfirmationLetters: 3, // Must type first 3 letters to unlock hints
|
||||
},
|
||||
{
|
||||
id: 'guided',
|
||||
label: 'Guided',
|
||||
emoji: '🎓',
|
||||
description: 'Maximum help - auto hints, hot/cold feedback, shows names on wrong clicks',
|
||||
hotColdEnabled: true,
|
||||
hintsMode: 'onRequest',
|
||||
autoHintDefault: true,
|
||||
struggleHintEnabled: true,
|
||||
giveUpMode: 'reaskSoon',
|
||||
wrongClickShowsName: true,
|
||||
},
|
||||
{
|
||||
id: 'helpful',
|
||||
label: 'Helpful',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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'
|
||||
export type AssistanceLevel = 'learning' | 'guided' | 'helpful' | 'standard' | 'none'
|
||||
|
||||
// Game configuration (persisted to database)
|
||||
export interface KnowYourWorldConfig extends GameConfig {
|
||||
|
|
|
|||
Loading…
Reference in New Issue