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:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
347
apps/web/src/arcade-games/know-your-world/Validator.test.ts
Normal file
347
apps/web/src/arcade-games/know-your-world/Validator.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user