feat: add give up with zoom animation for Know Your World

- Add Give Up button and 'G' keyboard shortcut during gameplay
- Zoom and center main map on revealed region with pulsing animation
- Fix Give Up button position during zoom animation (save position before transform)
- Remove magnifier during give-up (zoom animation makes it redundant)
- Add regionsGivenUp tracking and re-ask logic based on difficulty
- Add giveUpReveal state for animation coordination
- Add validator tests for give-up functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-25 16:12:10 -06:00
parent a492e3836b
commit 94cff4374f
13 changed files with 2401 additions and 696 deletions

View File

@@ -0,0 +1,419 @@
# Give Up Feature - Implementation Plan
## Overview
Add a "Give Up" button that allows users to skip a region they can't identify. When clicked:
1. **Flash the region** on the map (3 pulses over ~2 seconds)
2. **Temporarily label** the region with its name
3. **Show magnifier** if the region is small enough to need it
4. **Flash + label in magnifier** as well
5. **Move to next region** after animation completes
## Requirements Analysis
### User Experience
- User clicks "Give Up" in the GameInfoPanel
- Region flashes with a bright/pulsing color (yellow/gold to indicate "reveal")
- Region name appears as a label pointing to the region
- If region is tiny (needs magnifier), magnifier appears centered on that region with same flash/label
- After ~2 seconds, animation ends and game advances to next region
- Player receives no points for given-up regions
### Edge Cases
- Last region: give up ends the game (go to results)
- Turn-based mode: rotate to next player after give up
- Multiplayer: all players see the reveal animation
---
## Implementation Steps
### Step 1: Add Types (`types.ts`)
Add to `KnowYourWorldState`:
```typescript
// Give up reveal state
giveUpReveal: {
regionId: string
regionName: string
needsMagnifier: boolean
timestamp: number // For animation timing key
} | null
```
Add new move type:
```typescript
| {
type: 'GIVE_UP'
playerId: string
userId: string
timestamp: number
data: {}
}
```
### Step 2: Server-Side Logic (`Validator.ts`)
Add `validateGiveUp` method:
```typescript
private async validateGiveUp(state: KnowYourWorldState, playerId: string): Promise<ValidationResult> {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only give up during playing phase' }
}
if (!state.currentPrompt) {
return { valid: false, error: 'No region to give up on' }
}
// 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' }
}
// Get region info for the reveal
const mapData = await getFilteredMapDataLazy(state.selectedMap, state.selectedContinent, state.difficulty)
const region = mapData.regions.find(r => r.id === state.currentPrompt)
if (!region) {
return { valid: false, error: 'Region not found' }
}
// TODO: Determine if region needs magnifier (use region size threshold)
// For now, set needsMagnifier based on whether region is in known small regions list
// Or we can calculate it client-side since client has the rendered SVG
const newState: KnowYourWorldState = {
...state,
giveUpReveal: {
regionId: state.currentPrompt,
regionName: region.name,
needsMagnifier: false, // Client will determine this from rendered size
timestamp: Date.now(),
},
// Don't advance yet - wait for ADVANCE_AFTER_GIVE_UP move from client after animation
}
return { valid: true, newState }
}
```
Add `validateAdvanceAfterGiveUp` method (called after animation completes):
```typescript
private validateAdvanceAfterGiveUp(state: KnowYourWorldState): ValidationResult {
if (!state.giveUpReveal) {
return { valid: false, error: 'No active give up reveal' }
}
// Check if all regions are done
if (state.regionsToFind.length === 0) {
// Game complete
return {
valid: true,
newState: {
...state,
gamePhase: 'results',
currentPrompt: null,
giveUpReveal: null,
endTime: Date.now(),
}
}
}
// Move to next region
const nextPrompt = state.regionsToFind[0]
const remainingRegions = state.regionsToFind.slice(1)
// For turn-based, rotate player
let nextPlayer = state.currentPlayer
if (state.gameMode === 'turn-based') {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
nextPlayer = state.activePlayers[nextIndex]
}
return {
valid: true,
newState: {
...state,
currentPrompt: nextPrompt,
regionsToFind: remainingRegions,
currentPlayer: nextPlayer,
giveUpReveal: null,
}
}
}
```
**Alternative Design**: Single `GIVE_UP` move that immediately advances, with animation purely client-side:
- Simpler server logic
- Animation is fire-and-forget
- Risk: if user navigates away during animation, they've already lost the region
**Recommended**: Use single move for simplicity. Client shows animation, but state advances immediately.
### Step 3: Client Provider (`Provider.tsx`)
Add `giveUp` action:
```typescript
const giveUp = useCallback(() => {
sendMove({
type: 'GIVE_UP',
playerId: state.currentPlayer || activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
```
Add to context value.
### Step 4: UI Button (`GameInfoPanel.tsx`)
Add "Give Up" button below the current prompt:
```tsx
<button
onClick={giveUp}
disabled={!!state.giveUpReveal} // Disable during reveal animation
data-action="give-up"
className={css({
padding: '1 2',
fontSize: '2xs',
cursor: 'pointer',
bg: isDark ? 'yellow.800' : 'yellow.100',
color: isDark ? 'yellow.200' : 'yellow.800',
rounded: 'sm',
border: '1px solid',
borderColor: isDark ? 'yellow.600' : 'yellow.400',
fontWeight: 'bold',
transition: 'all 0.2s',
_hover: {
bg: isDark ? 'yellow.700' : 'yellow.200',
},
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
>
Give Up
</button>
```
### Step 5: Reveal Animation (`MapRenderer.tsx`)
This is the most complex part. Need to:
#### 5a. Accept `giveUpReveal` prop from PlayingPhase
```typescript
interface MapRendererProps {
// ... existing props
giveUpReveal: KnowYourWorldState['giveUpReveal']
onGiveUpAnimationComplete: () => void
}
```
#### 5b. Add animation state and effect
```typescript
const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing
useEffect(() => {
if (!giveUpReveal) {
setGiveUpFlashProgress(0)
return
}
const duration = 2000 // 2 seconds
const pulses = 3 // Number of full pulses
const startTime = Date.now()
const animate = () => {
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
setGiveUpFlashProgress(pulseProgress)
if (progress < 1) {
requestAnimationFrame(animate)
} else {
// Animation complete
setGiveUpFlashProgress(0)
onGiveUpAnimationComplete()
}
}
requestAnimationFrame(animate)
}, [giveUpReveal?.timestamp]) // Re-run when timestamp changes
```
#### 5c. Flash the region path
In the region rendering, check if this region is being revealed:
```typescript
const isBeingRevealed = giveUpReveal?.regionId === region.id
const fill = isBeingRevealed
? `rgba(255, 215, 0, ${0.3 + giveUpFlashProgress * 0.7})` // Gold flash
: /* existing fill logic */
```
#### 5d. Show temporary label
When `giveUpReveal` is active:
- Render a label element pointing to the region
- Use existing label positioning infrastructure or create a dedicated one
- Label should be prominent (larger font, contrasting background)
```tsx
{giveUpReveal && (
<RevealLabel
regionId={giveUpReveal.regionId}
regionName={giveUpReveal.regionName}
flashProgress={giveUpFlashProgress}
svgRef={svgRef}
isDark={isDark}
/>
)}
```
#### 5e. Force magnifier display for small regions
When `giveUpReveal` is active and region needs magnification:
```typescript
// Override magnifier visibility during give up
const shouldShowMagnifier = giveUpReveal
? giveUpRevealNeedsMagnifier
: (targetNeedsMagnification && hasSmallRegion)
```
Need to calculate whether the give-up region needs magnification:
```typescript
const [giveUpRevealNeedsMagnifier, setGiveUpRevealNeedsMagnifier] = useState(false)
useEffect(() => {
if (!giveUpReveal || !svgRef.current) {
setGiveUpRevealNeedsMagnifier(false)
return
}
const path = svgRef.current.querySelector(`path[data-region-id="${giveUpReveal.regionId}"]`)
if (!path || !(path instanceof SVGGeometryElement)) {
setGiveUpRevealNeedsMagnifier(false)
return
}
const bbox = path.getBoundingClientRect()
const isSmall = bbox.width < 15 || bbox.height < 15 || (bbox.width * bbox.height) < 200
setGiveUpRevealNeedsMagnifier(isSmall)
}, [giveUpReveal?.regionId])
```
#### 5f. Center magnifier on revealed region
When in give-up reveal mode with magnifier:
- Calculate the region's center in screen coordinates
- Position magnifier viewport to center on that region
- Don't track cursor movement during reveal
```typescript
// Calculate center of reveal region for magnifier positioning
const [revealCenterPosition, setRevealCenterPosition] = useState<{x: number, y: number} | null>(null)
useEffect(() => {
if (!giveUpReveal || !giveUpRevealNeedsMagnifier || !svgRef.current || !containerRef.current) {
setRevealCenterPosition(null)
return
}
const path = svgRef.current.querySelector(`path[data-region-id="${giveUpReveal.regionId}"]`)
if (!path) return
const pathBbox = path.getBoundingClientRect()
const containerRect = containerRef.current.getBoundingClientRect()
// Center of region in container coordinates
const centerX = pathBbox.left + pathBbox.width / 2 - containerRect.left
const centerY = pathBbox.top + pathBbox.height / 2 - containerRect.top
setRevealCenterPosition({ x: centerX, y: centerY })
}, [giveUpReveal?.regionId, giveUpRevealNeedsMagnifier])
```
Then use `revealCenterPosition` instead of `cursorPosition` for magnifier view calculations during reveal.
### Step 6: Update PlayingPhase
Pass new props to MapRenderer:
```tsx
<MapRenderer
// ... existing props
giveUpReveal={state.giveUpReveal}
onGiveUpAnimationComplete={() => {
// Send move to advance after animation
sendMove({
type: 'ADVANCE_AFTER_GIVE_UP',
// ...
})
}}
/>
```
Or if using single-move design, just let the animation complete without callback.
### Step 7: Storybook Update
Add story for reveal animation state.
---
## Design Decisions
### Single vs Two-Move Design
**Option A: Single GIVE_UP move (recommended)**
- Server immediately advances game state
- Client plays animation while already on "next region" conceptually
- Simpler, no race conditions
- Animation is purely cosmetic
**Option B: Two moves (GIVE_UP then ADVANCE)**
- More accurate state representation
- But adds complexity and timing concerns
- What if client disconnects during animation?
**Decision: Use Option A** - Single move, animation is fire-and-forget.
### Animation Duration
- 2 seconds total
- 3 pulses (on-off-on-off-on-off)
- Gold/yellow color for "reveal" semantics
- Smooth sine wave interpolation
### Label Styling
- Large, bold text
- High contrast background (dark bg in light mode, light bg in dark mode)
- Leader line pointing to region center
- Positioned to avoid overlap with region itself
---
## Files to Modify
1. `types.ts` - Add `giveUpReveal` to state, add `GIVE_UP` move type
2. `Validator.ts` - Add `validateGiveUp` case in switch, implement logic
3. `Provider.tsx` - Add `giveUp` action, expose in context
4. `GameInfoPanel.tsx` - Add "Give Up" button
5. `MapRenderer.tsx` - Add reveal animation, label, magnifier override
6. `PlayingPhase.tsx` - Pass `giveUpReveal` prop
7. `MapRenderer.stories.tsx` - Add story for reveal state
---
## Testing Checklist
- [ ] Give up on normal-sized region (no magnifier)
- [ ] Give up on tiny region (magnifier appears)
- [ ] Give up on last region (game ends)
- [ ] Turn-based mode: give up rotates player
- [ ] Multiplayer: all clients see animation
- [ ] Rapid give-ups don't break animation
- [ ] Button disabled during animation
- [ ] Label positioned correctly
- [ ] Flash color visible in both light/dark themes

View File

@@ -23,6 +23,7 @@ interface KnowYourWorldContextValue {
clickRegion: (regionId: string, regionName: string) => void
nextRound: () => void
endGame: () => void
giveUp: () => void
endStudy: () => void
returnToSetup: () => void
@@ -90,6 +91,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
currentPrompt: null,
regionsToFind: [],
regionsFound: [],
regionsGivenUp: [],
currentPlayer: '',
scores: {},
attempts: {},
@@ -97,6 +99,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
startTime: 0,
activePlayers: [],
playerMetadata: {},
giveUpReveal: null,
}
}, [roomData])
@@ -181,6 +184,16 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Action: Give Up (skip current region, reveal it, move to next)
const giveUp = useCallback(() => {
sendMove({
type: 'GIVE_UP',
playerId: state.currentPlayer || activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Setup Action: Set Map
const setMap = useCallback(
(selectedMap: 'world' | 'usa') => {
@@ -362,6 +375,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
clickRegion,
nextRound,
endGame,
giveUp,
endStudy,
returnToSetup,
setMap,

View File

@@ -0,0 +1,347 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { KnowYourWorldValidator } from './Validator'
import type { KnowYourWorldState, KnowYourWorldMove } from './types'
// Mock the maps module
vi.mock('./maps', () => ({
getFilteredMapData: vi.fn().mockResolvedValue({
regions: [
{ id: 'region-1', name: 'Region 1' },
{ id: 'region-2', name: 'Region 2' },
{ id: 'region-3', name: 'Region 3' },
{ id: 'region-4', name: 'Region 4' },
{ id: 'region-5', name: 'Region 5' },
{ id: 'region-6', name: 'Region 6' },
{ id: 'region-7', name: 'Region 7' },
{ id: 'region-8', name: 'Region 8' },
{ id: 'region-9', name: 'Region 9' },
{ id: 'region-10', name: 'Region 10' },
],
}),
}))
describe('KnowYourWorldValidator', () => {
let validator: KnowYourWorldValidator
beforeEach(() => {
validator = new KnowYourWorldValidator()
vi.clearAllMocks()
})
const createBaseState = (overrides: Partial<KnowYourWorldState> = {}): KnowYourWorldState => ({
gamePhase: 'playing',
selectedMap: 'world',
gameMode: 'cooperative',
difficulty: 'easy',
studyDuration: 0,
selectedContinent: 'all',
studyTimeRemaining: 0,
studyStartTime: 0,
currentPrompt: 'region-1',
regionsToFind: ['region-2', 'region-3', 'region-4', 'region-5'],
regionsFound: [],
regionsGivenUp: [],
currentPlayer: 'player-1',
scores: { 'player-1': 0 },
attempts: { 'player-1': 0 },
guessHistory: [],
startTime: Date.now(),
activePlayers: ['player-1'],
playerMetadata: { 'player-1': { name: 'Player 1' } },
giveUpReveal: null,
...overrides,
})
const createGiveUpMove = (playerId = 'player-1'): KnowYourWorldMove => ({
type: 'GIVE_UP',
playerId,
userId: 'user-1',
timestamp: Date.now(),
data: {},
})
describe('validateGiveUp', () => {
describe('validation checks', () => {
it('rejects give up when not in playing phase', async () => {
const state = createBaseState({ gamePhase: 'setup' })
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(false)
expect(result.error).toBe('Can only give up during playing phase')
})
it('rejects give up when no current prompt', async () => {
const state = createBaseState({ currentPrompt: null })
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(false)
expect(result.error).toBe('No region to give up on')
})
it('rejects give up from wrong player in turn-based mode', async () => {
const state = createBaseState({
gameMode: 'turn-based',
currentPlayer: 'player-1',
activePlayers: ['player-1', 'player-2'],
})
const move = createGiveUpMove('player-2')
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(false)
expect(result.error).toBe('Not your turn')
})
it('accepts give up from correct player in turn-based mode', async () => {
const state = createBaseState({
gameMode: 'turn-based',
currentPlayer: 'player-1',
activePlayers: ['player-1', 'player-2'],
})
const move = createGiveUpMove('player-1')
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
})
})
describe('region tracking', () => {
it('adds region to regionsGivenUp', async () => {
const state = createBaseState({
currentPrompt: 'region-1',
regionsGivenUp: [],
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.regionsGivenUp).toContain('region-1')
})
it('does not duplicate region in regionsGivenUp if already tracked', async () => {
const state = createBaseState({
currentPrompt: 'region-1',
regionsGivenUp: ['region-1'], // Already given up once
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.regionsGivenUp).toEqual(['region-1'])
expect(result.newState?.regionsGivenUp?.length).toBe(1)
})
it('preserves existing regionsGivenUp entries', async () => {
const state = createBaseState({
currentPrompt: 'region-2',
regionsGivenUp: ['region-1'],
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.regionsGivenUp).toContain('region-1')
expect(result.newState?.regionsGivenUp).toContain('region-2')
})
})
describe('re-ask priority based on difficulty', () => {
it('re-inserts region after 3 positions on easy difficulty', async () => {
const state = createBaseState({
difficulty: 'easy',
currentPrompt: 'region-1',
regionsToFind: ['region-2', 'region-3', 'region-4', 'region-5', 'region-6'],
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
// Next prompt should be region-2 (first in queue)
expect(result.newState?.currentPrompt).toBe('region-2')
// region-1 re-inserted at position 3 in queue, then first element becomes currentPrompt
// Queue after insert: [region-2, region-3, region-4, region-1, region-5, region-6]
// After slice(1): [region-3, region-4, region-1, region-5, region-6]
// So user will see: region-2, region-3, region-4, then region-1 comes back
expect(result.newState?.regionsToFind).toEqual([
'region-3',
'region-4',
'region-1',
'region-5',
'region-6',
])
})
it('re-inserts region at end on hard difficulty', async () => {
const state = createBaseState({
difficulty: 'hard',
currentPrompt: 'region-1',
regionsToFind: ['region-2', 'region-3', 'region-4', 'region-5'],
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.currentPrompt).toBe('region-2')
// region-1 should be at the end
expect(result.newState?.regionsToFind).toEqual([
'region-3',
'region-4',
'region-5',
'region-1',
])
})
it('handles easy difficulty with fewer than 3 regions remaining', async () => {
const state = createBaseState({
difficulty: 'easy',
currentPrompt: 'region-1',
regionsToFind: ['region-2'], // Only 1 region in queue
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.currentPrompt).toBe('region-2')
// region-1 should be inserted at end (min of 3, queue length)
expect(result.newState?.regionsToFind).toEqual(['region-1'])
})
it('handles only one region (the current prompt) remaining', async () => {
const state = createBaseState({
difficulty: 'easy',
currentPrompt: 'region-1',
regionsToFind: [], // No other regions
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
// The given-up region becomes the next prompt again
expect(result.newState?.currentPrompt).toBe('region-1')
expect(result.newState?.regionsToFind).toEqual([])
})
})
describe('turn rotation in turn-based mode', () => {
it('rotates to next player after give up', async () => {
const state = createBaseState({
gameMode: 'turn-based',
currentPlayer: 'player-1',
activePlayers: ['player-1', 'player-2', 'player-3'],
})
const move = createGiveUpMove('player-1')
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.currentPlayer).toBe('player-2')
})
it('wraps around to first player after last player', async () => {
const state = createBaseState({
gameMode: 'turn-based',
currentPlayer: 'player-3',
activePlayers: ['player-1', 'player-2', 'player-3'],
})
const move = createGiveUpMove('player-3')
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.currentPlayer).toBe('player-1')
})
it('does not rotate player in cooperative mode', async () => {
const state = createBaseState({
gameMode: 'cooperative',
currentPlayer: 'player-1',
activePlayers: ['player-1', 'player-2'],
})
const move = createGiveUpMove('player-1')
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.currentPlayer).toBe('player-1')
})
})
describe('giveUpReveal state', () => {
it('sets giveUpReveal with region info and timestamp', async () => {
const state = createBaseState({
currentPrompt: 'region-1',
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.giveUpReveal).not.toBeNull()
expect(result.newState?.giveUpReveal?.regionId).toBe('region-1')
expect(result.newState?.giveUpReveal?.regionName).toBe('Region 1')
expect(result.newState?.giveUpReveal?.timestamp).toBeGreaterThan(0)
})
})
describe('integration: multiple give ups', () => {
it('handles consecutive give ups correctly', async () => {
// First give up
let state = createBaseState({
difficulty: 'easy',
currentPrompt: 'region-1',
regionsToFind: ['region-2', 'region-3', 'region-4', 'region-5'],
})
let move = createGiveUpMove()
let result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.regionsGivenUp).toEqual(['region-1'])
// Second give up (on region-2)
state = result.newState!
move = createGiveUpMove()
result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
expect(result.newState?.regionsGivenUp).toContain('region-1')
expect(result.newState?.regionsGivenUp).toContain('region-2')
expect(result.newState?.currentPrompt).toBe('region-3')
})
it('handles giving up on same region twice (after it comes back)', async () => {
// Setup: region-1 is current, only region-2 in queue
// After give up: region-2 is current, region-1 at end
// Then region-2 is found, region-1 comes back as current
// Give up again on region-1
const state = createBaseState({
difficulty: 'hard',
currentPrompt: 'region-1',
regionsToFind: ['region-2'],
regionsGivenUp: ['region-1'], // Already given up once
})
const move = createGiveUpMove()
const result = await validator.validateMove(state, move)
expect(result.valid).toBe(true)
// Should not duplicate in regionsGivenUp
expect(result.newState?.regionsGivenUp).toEqual(['region-1'])
// region-1 should still be re-inserted
expect(result.newState?.regionsToFind).toContain('region-1')
})
})
})
})

View File

@@ -47,6 +47,8 @@ export class KnowYourWorldValidator
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)
default:
return { valid: false, error: 'Unknown move type' }
}
@@ -92,11 +94,13 @@ export class KnowYourWorldValidator
currentPrompt: shouldStudy ? null : shuffledRegions[0],
regionsToFind: shuffledRegions.slice(shouldStudy ? 0 : 1),
regionsFound: [],
regionsGivenUp: [],
currentPlayer: activePlayers[0],
scores,
attempts,
guessHistory: [],
startTime: Date.now(),
giveUpReveal: null,
}
return { valid: true, newState }
@@ -160,6 +164,7 @@ export class KnowYourWorldValidator
scores: newScores,
guessHistory,
endTime: Date.now(),
giveUpReveal: null,
}
return { valid: true, newState }
}
@@ -184,6 +189,7 @@ export class KnowYourWorldValidator
currentPlayer: nextPlayer,
scores: newScores,
guessHistory,
giveUpReveal: null,
}
return { valid: true, newState }
@@ -250,12 +256,14 @@ export class KnowYourWorldValidator
currentPrompt: shouldStudy ? null : shuffledRegions[0],
regionsToFind: shuffledRegions.slice(shouldStudy ? 0 : 1),
regionsFound: [],
regionsGivenUp: [],
currentPlayer: state.activePlayers[0],
scores,
attempts,
guessHistory: [],
startTime: Date.now(),
endTime: undefined,
giveUpReveal: null,
}
return { valid: true, newState }
@@ -382,6 +390,7 @@ export class KnowYourWorldValidator
currentPrompt: null,
regionsToFind: [],
regionsFound: [],
regionsGivenUp: [],
currentPlayer: '',
scores: {},
attempts: {},
@@ -390,6 +399,83 @@ export class KnowYourWorldValidator
endTime: undefined,
studyTimeRemaining: 0,
studyStartTime: 0,
giveUpReveal: null,
}
return { valid: true, newState }
}
private async validateGiveUp(
state: KnowYourWorldState,
playerId: string
): Promise<ValidationResult> {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only give up during playing phase' }
}
if (!state.currentPrompt) {
return { valid: false, error: 'No region to give up on' }
}
// 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' }
}
// Get region info for the reveal
const mapData = await getFilteredMapDataLazy(
state.selectedMap,
state.selectedContinent,
state.difficulty
)
const region = mapData.regions.find((r) => r.id === state.currentPrompt)
if (!region) {
return { valid: false, error: 'Region not found' }
}
// Track this region as given up (add if not already tracked)
// Use fallback for older game states that don't have regionsGivenUp
const existingGivenUp = state.regionsGivenUp ?? []
const regionsGivenUp = existingGivenUp.includes(region.id)
? 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
// Build new regions queue: take next regions, then insert given-up region at appropriate position
const remainingRegions = [...state.regionsToFind]
// Insert the given-up region back into the queue
const insertPosition = Math.min(reaskDelay, remainingRegions.length)
remainingRegions.splice(insertPosition, 0, region.id)
// If there are no other regions (only the one we just gave up), it will be re-asked immediately
const nextPrompt = remainingRegions[0]
const newRegionsToFind = remainingRegions.slice(1)
// For turn-based, rotate player
let nextPlayer = state.currentPlayer
if (state.gameMode === 'turn-based') {
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
const nextIndex = (currentIndex + 1) % state.activePlayers.length
nextPlayer = state.activePlayers[nextIndex]
}
const newState: KnowYourWorldState = {
...state,
currentPrompt: nextPrompt,
regionsToFind: newRegionsToFind,
regionsGivenUp,
currentPlayer: nextPlayer,
giveUpReveal: {
regionId: region.id,
regionName: region.name,
timestamp: Date.now(),
},
}
return { valid: true, newState }
@@ -414,6 +500,7 @@ export class KnowYourWorldValidator
currentPrompt: null,
regionsToFind: [],
regionsFound: [],
regionsGivenUp: [],
currentPlayer: '',
scores: {},
attempts: {},
@@ -421,6 +508,7 @@ export class KnowYourWorldValidator
startTime: 0,
activePlayers: [],
playerMetadata: {},
giveUpReveal: null,
}
}

View File

@@ -1,11 +1,14 @@
'use client'
import { useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
import type { MapData } from '../types'
// Animation duration in ms - must match MapRenderer
const GIVE_UP_ANIMATION_DURATION = 2000
interface GameInfoPanelProps {
mapData: MapData
currentRegionName: string | null
@@ -23,7 +26,54 @@ export function GameInfoPanel({
}: GameInfoPanelProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { state, lastError, clearError } = useKnowYourWorld()
const { state, lastError, clearError, giveUp } = useKnowYourWorld()
// Track if animation is in progress (local state based on timestamp)
const [isAnimating, setIsAnimating] = useState(false)
// Check if animation is in progress based on timestamp
useEffect(() => {
if (!state.giveUpReveal?.timestamp) {
setIsAnimating(false)
return
}
const elapsed = Date.now() - state.giveUpReveal.timestamp
if (elapsed < GIVE_UP_ANIMATION_DURATION) {
setIsAnimating(true)
// Clear animation flag after remaining time
const timeout = setTimeout(() => {
setIsAnimating(false)
}, GIVE_UP_ANIMATION_DURATION - elapsed)
return () => clearTimeout(timeout)
} else {
setIsAnimating(false)
}
}, [state.giveUpReveal?.timestamp])
// Handle give up with keyboard shortcut (G key)
const handleGiveUp = useCallback(() => {
if (!isAnimating && state.gamePhase === 'playing') {
giveUp()
}
}, [isAnimating, state.gamePhase, giveUp])
// Keyboard shortcut for give up (works even in pointer lock mode)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 'G' key for Give Up
if (e.key === 'g' || e.key === 'G') {
// Don't trigger if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
handleGiveUp()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleGiveUp])
// Auto-dismiss errors after 3 seconds
useEffect(() => {
@@ -67,6 +117,9 @@ export function GameInfoPanel({
border: '2px solid',
borderColor: 'blue.500',
minWidth: 0, // Allow shrinking
display: 'flex',
flexDirection: 'column',
gap: '1',
})}
>
<div

View File

@@ -112,6 +112,8 @@ const Template = (args: StoryArgs) => {
onRegionClick={(id, name) => console.log('Clicked:', id, name)}
guessHistory={guessHistory}
playerMetadata={mockPlayerMetadata}
giveUpReveal={null}
onGiveUp={() => console.log('Give Up clicked')}
forceTuning={{
showArrows: args.showArrows,
centeringStrength: args.centeringStrength,

View File

@@ -8,7 +8,7 @@ import { MapRenderer } from './MapRenderer'
import { GameInfoPanel } from './GameInfoPanel'
export function PlayingPhase() {
const { state, clickRegion } = useKnowYourWorld()
const { state, clickRegion, giveUp } = useKnowYourWorld()
const mapData = getFilteredMapDataSync(
state.selectedMap,
@@ -112,6 +112,8 @@ export function PlayingPhase() {
onRegionClick={clickRegion}
guessHistory={state.guessHistory}
playerMetadata={state.playerMetadata}
giveUpReveal={state.giveUpReveal}
onGiveUp={giveUp}
/>
</div>
</Panel>

View File

@@ -322,6 +322,23 @@ export function SetupPhase() {
)
})}
</div>
{/* Give Up behavior note */}
<div
data-element="give-up-note"
className={css({
marginTop: '3',
padding: '3',
bg: isDark ? 'gray.800' : 'gray.100',
rounded: 'md',
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
<strong>Tip:</strong> Press G or click "Give Up" to skip a region you don't know.{' '}
{state.difficulty === 'easy'
? 'On Easy, skipped regions will be re-asked after a few turns.'
: 'On Hard, skipped regions will be re-asked at the end.'}
</div>
</div>
)}

View File

@@ -61,6 +61,10 @@ export interface UseRegionDetectionOptions {
smallRegionThreshold?: number
/** Area threshold for small regions (default: 200px²) */
smallRegionAreaThreshold?: number
/** Cache of pre-computed sizes for multi-piece regions (mainland only) */
largestPieceSizesCache?: Map<string, { width: number; height: number }>
/** Regions that have been found - excluded from zoom level calculations */
regionsFound?: string[]
}
export interface UseRegionDetectionReturn {
@@ -92,6 +96,8 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
detectionBoxSize = 50,
smallRegionThreshold = 15,
smallRegionAreaThreshold = 200,
largestPieceSizesCache,
regionsFound = [],
} = options
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
@@ -172,32 +178,7 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
}
}
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
mapData.regions.forEach((region) => {
// PERFORMANCE: Quick distance check using pre-computed center
// This avoids expensive DOM queries for regions far from cursor
// Region center is in SVG coordinates, convert to screen coords
const svgCenter = svgElement.createSVGPoint()
svgCenter.x = region.center[0]
svgCenter.y = region.center[1]
const screenCenter = svgCenter.matrixTransform(screenCTM)
// Calculate rough distance from cursor to region center
const dx = screenCenter.x - cursorClientX
const dy = screenCenter.y - cursorClientY
const distanceSquared = dx * dx + dy * dy
// Skip regions whose centers are very far from the detection box
// Detection box is 50px, but we check 100px radius to provide smooth transitions
// Regions at 50-100px will have very low importance (smooth cubic falloff)
const MAX_DISTANCE = 100
if (distanceSquared > MAX_DISTANCE * MAX_DISTANCE) {
return // Region is definitely too far away
}
// Get cached path element (populated in useEffect)
const regionPath = pathElementCache.current.get(region.id)
if (!regionPath) return
@@ -205,6 +186,8 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
const pathRect = regionPath.getBoundingClientRect()
// Check if bounding box overlaps with detection box
// This is efficient and works correctly for regions of all sizes
// (unlike center-distance checks which fail for large regions like Russia)
const regionLeft = pathRect.left
const regionRight = pathRect.right
const regionTop = pathRect.top
@@ -253,21 +236,28 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
// If detection box overlaps with actual path geometry, add to detected regions
if (overlaps) {
const pixelWidth = pathRect.width
const pixelHeight = pathRect.height
// Use cached size for multi-piece regions (mainland only, not full bounding box)
const cachedSize = largestPieceSizesCache?.get(region.id)
const pixelWidth = cachedSize?.width ?? pathRect.width
const pixelHeight = cachedSize?.height ?? pathRect.height
const pixelArea = pixelWidth * pixelHeight
const isVerySmall =
pixelWidth < smallRegionThreshold ||
pixelHeight < smallRegionThreshold ||
pixelArea < smallRegionAreaThreshold
if (isVerySmall) {
hasSmallRegion = true
}
const screenSize = Math.min(pixelWidth, pixelHeight)
totalRegionArea += pixelArea
detectedSmallestSize = Math.min(detectedSmallestSize, screenSize)
// Only count unfound regions toward zoom calculations
// Found regions shouldn't influence hasSmallRegion or detectedSmallestSize
const isFound = regionsFound.includes(region.id)
if (!isFound) {
if (isVerySmall) {
hasSmallRegion = true
}
totalRegionArea += pixelArea
detectedSmallestSize = Math.min(detectedSmallestSize, screenSize)
}
detected.push({
id: region.id,
@@ -300,6 +290,8 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
detectionBoxSize,
smallRegionThreshold,
smallRegionAreaThreshold,
largestPieceSizesCache,
regionsFound,
]
)

View File

@@ -53,15 +53,16 @@ export function getRegionColor(
// Found: use base color with full opacity
return color.base
} else if (isHovered) {
// Hovered: use light color with medium opacity
// Hovered: use light color with good opacity for clear feedback
return isDark
? `${color.light}66` // 40% opacity in dark mode
: `${color.base}55` // 33% opacity in light mode
? `${color.light}99` // 60% opacity in dark mode
: `${color.base}77` // 47% opacity in light mode
} else {
// Not found: use very light color with low opacity
// Not found: use earth-tone colors for land masses
// Higher opacity and warmer colors distinguish land from sea
return isDark
? `${color.light}33` // 20% opacity in dark mode
: `${color.light}44` // 27% opacity in light mode
? '#4a5568' // Warm gray in dark mode - clearly land
: '#d4c4a8' // Sandy/earthy beige in light mode - clearly land
}
}
@@ -72,7 +73,8 @@ export function getRegionStroke(isFound: boolean, isDark: boolean): string {
if (isFound) {
return isDark ? '#ffffff' : '#000000' // High contrast for found regions
}
return isDark ? '#1f2937' : '#ffffff' // Subtle border for unfound regions
// More visible borders for unfound regions - darker to contrast with land colors
return isDark ? '#2d3748' : '#8b7355' // Dark gray / brown border
}
/**

View File

@@ -56,6 +56,7 @@ export interface KnowYourWorldState extends GameState {
currentPrompt: string | null // Region name to find (e.g., "France")
regionsToFind: string[] // Queue of region IDs still to find
regionsFound: string[] // Region IDs already found
regionsGivenUp: string[] // Region IDs that were given up (for re-asking and scoring)
currentPlayer: string // For turn-based mode
// Scoring
@@ -70,6 +71,13 @@ export interface KnowYourWorldState extends GameState {
// Multiplayer
activePlayers: string[]
playerMetadata: Record<string, any>
// Give up reveal state (for animation)
giveUpReveal: {
regionId: string
regionName: string
timestamp: number // For animation timing key
} | null
}
// Move types
@@ -170,3 +178,10 @@ export type KnowYourWorldMove =
selectedContinent: ContinentId | 'all'
}
}
| {
type: 'GIVE_UP'
playerId: string
userId: string
timestamp: number
data: {}
}

View File

@@ -220,7 +220,7 @@ function calculateRegionImportance(
// - Faster decrease at mid-range
// - Very gradual approach to zero at edge (no discontinuity)
const normalizedDistance = Math.min(distanceToCursor / 50, 1)
const distanceWeight = Math.max(0, 1 - Math.pow(normalizedDistance, 3))
const distanceWeight = Math.max(0, 1 - normalizedDistance ** 3)
// 2. Size factor: Smaller regions get boosted importance
// This ensures San Marino can be targeted even when Italy is closer to cursor
@@ -353,34 +353,36 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
}
// Track bounding boxes for debug visualization - add ALL detected regions upfront
const boundingBoxes: BoundingBox[] = sortedRegions.map(({ region: detectedRegion, importance }) => {
const regionPath = svgElement.querySelector(`path[data-region-id="${detectedRegion.id}"]`)
if (!regionPath) {
const boundingBoxes: BoundingBox[] = sortedRegions
.map(({ region: detectedRegion, importance }) => {
const regionPath = svgElement.querySelector(`path[data-region-id="${detectedRegion.id}"]`)
if (!regionPath) {
return {
regionId: detectedRegion.id,
x: 0,
y: 0,
width: 0,
height: 0,
importance,
wasAccepted: false,
}
}
const pathRect = regionPath.getBoundingClientRect()
const regionSvgLeft = (pathRect.left - svgRect.left) * scaleX + viewBoxX
const regionSvgTop = (pathRect.top - svgRect.top) * scaleY + viewBoxY
return {
regionId: detectedRegion.id,
x: 0,
y: 0,
width: 0,
height: 0,
x: regionSvgLeft,
y: regionSvgTop,
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
importance,
wasAccepted: false,
}
}
const pathRect = regionPath.getBoundingClientRect()
const regionSvgLeft = (pathRect.left - svgRect.left) * scaleX + viewBoxX
const regionSvgTop = (pathRect.top - svgRect.top) * scaleY + viewBoxY
return {
regionId: detectedRegion.id,
x: regionSvgLeft,
y: regionSvgTop,
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
importance,
wasAccepted: false,
}
}).filter((bbox) => bbox.width > 0 && bbox.height > 0)
})
.filter((bbox) => bbox.width > 0 && bbox.height > 0)
// Track detailed decision information for each region
const regionDecisions: RegionZoomDecision[] = []
@@ -539,7 +541,9 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Didn't find a good zoom - use calculated minimum
optimalZoom = calculatedMinZoom
if (pointerLocked) {
console.log(`[Zoom Search] ⚠️ No good zoom found, using calculated minimum: ${calculatedMinZoom}x`)
console.log(
`[Zoom Search] ⚠️ No good zoom found, using calculated minimum: ${calculatedMinZoom}x`
)
}
}