feat(know-your-world): add fire tracer animation for learning mode takeover

Add animated fire/sparkle tracer effect around region outlines in learning mode:
- Tracer follows simplified path (using simplify-js) for smooth coastlines
- Supports multiple islands with simultaneous animations
- Progressive intensity as letters are typed:
  - Speed increases exponentially (15s → 0.075s, 200x faster)
  - Opacity fades in (25% → 100%)
  - Sparkle count grows exponentially (1 → 48)
- 750ms laser effect delay after final letter before dismissing
- Uses react-spring for smooth transitions
- Performance optimized: removed filters from particles

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-01 09:44:03 -06:00
parent f8acc4aa6a
commit 1e6153ee8b
4 changed files with 416 additions and 75 deletions

View File

@ -95,6 +95,7 @@
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-html": "^16.0.1",
"simplify-js": "^1.2.4",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"three": "^0.169.0",

View File

@ -33,7 +33,9 @@ export function getNthNonSpaceLetter(
* Get Unicode code points for a string (for debugging)
*/
function getCodePoints(str: string): string {
return [...str].map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`).join(' ')
return [...str]
.map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`)
.join(' ')
}
/**

View File

@ -24,6 +24,8 @@ import {
} from '../utils/guidanceVisibility'
import { SimpleLetterKeyboard, useIsTouchDevice } from './SimpleLetterKeyboard'
import { MusicControlModal, useMusic } from '../music'
import simplify from 'simplify-js'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
// Animation duration in ms - must match MapRenderer
const GIVE_UP_ANIMATION_DURATION = 2000
@ -36,7 +38,9 @@ const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 }
* Get Unicode code points for a string (for debugging)
*/
function getCodePoints(str: string): string {
return [...str].map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`).join(' ')
return [...str]
.map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`)
.join(' ')
}
/**
@ -62,6 +66,19 @@ function normalizeToBaseLetter(char: string): string {
return result
}
/**
* Convert {x, y} points array to SVG path string.
*/
function pointsToSvgPath(points: Array<{ x: number; y: number }>): string {
if (points.length === 0) return ''
let path = `M ${points[0].x} ${points[0].y}`
for (let i = 1; i < points.length; i++) {
path += ` L ${points[i].x} ${points[i].y}`
}
path += ' Z'
return path
}
// Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji)
function getHotColdEmoji(type: FeedbackType | null | undefined): string {
if (!type) return '🔥'
@ -113,6 +130,7 @@ export function GameInfoPanel({
}: GameInfoPanelProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { isVisualDebugEnabled } = useVisualDebugSafe()
const {
state,
lastError,
@ -237,26 +255,107 @@ export function GameInfoPanel({
height: number
} | null>(null)
// Measure accurate bounding box using hidden SVG + getBBox()
// Simplified path for tracer animation - calculated using browser's native path methods
const [simplifiedTracerPaths, setSimplifiedTracerPaths] = useState<string[] | null>(null)
// Measure accurate bounding box AND generate simplified tracer path using hidden SVG
// This ensures part 1 and part 2 use identical positioning
useEffect(() => {
if (hiddenPathRef.current && displayRegionPath) {
// Use requestAnimationFrame to ensure path is rendered
requestAnimationFrame(() => {
if (hiddenPathRef.current) {
const bbox = hiddenPathRef.current.getBBox()
const pathEl = hiddenPathRef.current
const bbox = pathEl.getBBox()
setAccurateBBox({
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
})
// Sample points along the path using browser's native getPointAtLength()
// This correctly handles all SVG path commands
const totalLength = pathEl.getTotalLength()
const numSamples = 500 // Sample more points for better detection of jumps
const allPoints: Array<{ x: number; y: number }> = []
for (let i = 0; i <= numSamples; i++) {
const distance = (i / numSamples) * totalLength
const point = pathEl.getPointAtLength(distance)
allPoints.push({ x: point.x, y: point.y })
}
// Detect jumps between sub-paths and split into separate segments
// A jump is when consecutive points are much farther apart than average
const segments: Array<Array<{ x: number; y: number }>> = []
let currentSegment: Array<{ x: number; y: number }> = [allPoints[0]]
// Calculate average distance between consecutive points
let totalDist = 0
for (let i = 1; i < allPoints.length; i++) {
const dx = allPoints[i].x - allPoints[i - 1].x
const dy = allPoints[i].y - allPoints[i - 1].y
totalDist += Math.sqrt(dx * dx + dy * dy)
}
const avgDist = totalDist / (allPoints.length - 1)
const jumpThreshold = avgDist * 5 // A jump is 5x the average distance
for (let i = 1; i < allPoints.length; i++) {
const dx = allPoints[i].x - allPoints[i - 1].x
const dy = allPoints[i].y - allPoints[i - 1].y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist > jumpThreshold) {
// This is a jump - save current segment and start new one
if (currentSegment.length > 2) {
segments.push(currentSegment)
}
currentSegment = [allPoints[i]]
} else {
currentSegment.push(allPoints[i])
}
}
// Don't forget the last segment
if (currentSegment.length > 2) {
segments.push(currentSegment)
}
// Simplify each segment - keep separate for simultaneous animations on each island
const tolerance = Math.max(bbox.width, bbox.height) * 0.02 // 2% tolerance (less aggressive)
const simplifiedSegments = segments.map((seg) => simplify(seg, tolerance, true))
// Convert each segment to a separate SVG path string
const pathStrings: string[] = []
let totalSimplifiedPoints = 0
for (const simplified of simplifiedSegments) {
if (simplified.length > 2) {
let pathStr = `M ${simplified[0].x} ${simplified[0].y}`
for (let i = 1; i < simplified.length; i++) {
pathStr += ` L ${simplified[i].x} ${simplified[i].y}`
}
pathStr += ' Z'
pathStrings.push(pathStr)
totalSimplifiedPoints += simplified.length
}
}
setSimplifiedTracerPaths(pathStrings)
console.log('[PathSimplify]', {
regionId: displayRegionId,
totalLength,
sampledPoints: allPoints.length,
segments: segments.length,
simplifiedPoints: totalSimplifiedPoints,
reduction: `${Math.round((1 - totalSimplifiedPoints / allPoints.length) * 100)}%`,
})
}
})
} else {
setAccurateBBox(null)
setSimplifiedTracerPaths(null)
}
}, [displayRegionPath])
}, [displayRegionPath, displayRegionId])
// Get the region's SVG path for puzzle piece animation
// Uses the exact SVG bounding box from getBBox() passed in the target
@ -411,16 +510,57 @@ export function GameInfoPanel({
},
})
// Tracer intensity spring - controls speed, size, opacity, and focus based on letter progress
// 0 letters = slow/big/faint, all letters = fast/laser-focused/bright
// All values smoothly animated via react-spring
const tracerIntensity =
requiresNameConfirmation > 0 ? confirmedLetterCount / requiresNameConfirmation : 0
// Exponential curve for more dramatic pickup (x^3 gives steep curve at the end)
const exponentialIntensity = Math.pow(tracerIntensity, 2.5)
const tracerSpring = useSpring({
// Size multiplier: 1.5 (big) → 0.3 (laser-focused)
sizeScale: 1.5 - exponentialIntensity * 1.2,
// Glow multiplier: 1.5 (soft/large) → 0.4 (concentrated)
glowScale: 1.5 - exponentialIntensity * 1.1,
// Ember size multiplier
emberScale: 1.0 - exponentialIntensity * 0.6,
// Overall opacity: 25% at 0 letters → 100% at all letters (smoothly animated)
overallOpacity: 0.25 + tracerIntensity * 0.75,
// Speed multiplier for duration calculation: 1 (slow) → 200 (blazing fast)
// Using exponential curve: 1 → 5 → 40 → 200
speedMultiplier: 1 + exponentialIntensity * 199,
config: { tension: 180, friction: 18 },
})
// Duration for tracer animation - computed from exponential intensity
// Base duration 15s divided by speed: 15s → 3s → 0.375s → 0.075s (200x faster at max!)
const tracerDuration = 15 / (1 + exponentialIntensity * 199)
// Exponential sparkle counts - dramatic growth but capped for performance
// Embers: 1 → 3 → 8 → 16 (filtered glow, keep lower)
// Flying sparks: 2 → 8 → 24 → 48 (no filter, can have more)
const emberCount =
requiresNameConfirmation > 0 ? [1, 3, 8, 16][Math.min(confirmedLetterCount, 3)] : 1
const sparkCount =
requiresNameConfirmation > 0 ? [2, 8, 24, 48][Math.min(confirmedLetterCount, 3)] : 2
// Whether we're in puzzle piece animation mode (has sourceRect means animation is active)
const isPuzzlePieceAnimating = puzzlePieceTarget !== null && puzzlePieceTarget.sourceRect !== undefined
const isPuzzlePieceAnimating =
puzzlePieceTarget !== null && puzzlePieceTarget.sourceRect !== undefined
// Whether we're in the "fade back in" phase (target set, waiting for sourceRect capture)
const isFadingBackIn = puzzlePieceTarget !== null && puzzlePieceTarget.sourceRect === undefined
// Track if we're showing the laser effect (brief delay after all letters entered)
const [showingLaserEffect, setShowingLaserEffect] = useState(false)
// Memoize whether we're in active takeover mode
// Takeover visible: during typing OR when fading back in for animation OR during animation
// Takeover visible: during typing OR showing laser effect OR fading back in OR during animation
const isInTakeoverLocal =
isLearningMode && (takeoverProgress < 1 || isFadingBackIn || isPuzzlePieceAnimating)
isLearningMode &&
(takeoverProgress < 1 || showingLaserEffect || isFadingBackIn || isPuzzlePieceAnimating)
const showPulseAnimation = isLearningMode && takeoverProgress < 0.5
// Sync takeover state to context (so MapRenderer can suppress hot/cold feedback)
@ -434,6 +574,7 @@ export function GameInfoPanel({
useEffect(() => {
if (currentRegionId) {
setNameConfirmed(false)
setShowingLaserEffect(false)
setIsAttentionPhase(true)
// End attention phase after duration
@ -452,8 +593,17 @@ export function GameInfoPanel({
confirmedLetterCount >= requiresNameConfirmation &&
!nameConfirmed
) {
setNameConfirmed(true)
// Start showing laser effect
setShowingLaserEffect(true)
onHintsUnlock?.()
// After 750ms, hide the laser effect and mark as confirmed
const timeout = setTimeout(() => {
setShowingLaserEffect(false)
setNameConfirmed(true)
}, 750)
return () => clearTimeout(timeout)
}
}, [confirmedLetterCount, requiresNameConfirmation, nameConfirmed, onHintsUnlock])
@ -747,7 +897,11 @@ export function GameInfoPanel({
{/* Region shape silhouette - shown during takeover, until animation starts */}
{/* Uses accurateBBox from getBBox() for consistent positioning between parts 1 and 2 */}
{displayRegionPath && accurateBBox && isInTakeoverLocal && !isPuzzlePieceAnimating && (() => {
{displayRegionPath &&
accurateBBox &&
isInTakeoverLocal &&
!isPuzzlePieceAnimating &&
(() => {
// Use puzzlePieceTarget.svgBBox if available (part 2), otherwise use accurateBBox (part 1)
// Both come from getBBox() so positioning should be identical
const bbox = puzzlePieceTarget?.svgBBox ?? accurateBBox
@ -755,7 +909,8 @@ export function GameInfoPanel({
const aspectRatio = bbox.width / bbox.height
// Calculate container size that fits within 60% of viewport while preserving aspect ratio
const maxSize = typeof window !== 'undefined'
const maxSize =
typeof window !== 'undefined'
? Math.min(window.innerWidth, window.innerHeight) * 0.6
: 400
@ -775,6 +930,17 @@ export function GameInfoPanel({
const x = typeof window !== 'undefined' ? (window.innerWidth - width) / 2 : 200
const y = typeof window !== 'undefined' ? (window.innerHeight - height) / 2 : 100
// Calculate a reasonable tracer size based on the viewBox dimensions
const tracerSize = Math.max(bbox.width, bbox.height) * 0.03
// Use pre-calculated simplified paths from state (calculated using browser's getPointAtLength)
// Each element is a separate island/segment - we animate all simultaneously
// Fall back to original path if simplified not ready yet
const tracerPaths =
simplifiedTracerPaths && simplifiedTracerPaths.length > 0
? simplifiedTracerPaths
: [displayRegionPath]
return (
<svg
ref={takeoverRegionShapeRef}
@ -789,15 +955,179 @@ export function GameInfoPanel({
height: `${height}px`,
zIndex: 151,
pointerEvents: 'none',
overflow: 'visible',
}}
>
{/* Definitions for glow effects */}
<defs>
{/* Simplified paths for smooth animation - one per island/segment */}
{tracerPaths.map((path, idx) => (
<path key={`motion-path-${idx}`} id={`tracer-motion-path-${idx}`} d={path} />
))}
</defs>
{/* DEBUG: Render simplified paths visibly (only when visual debug enabled) */}
{isVisualDebugEnabled &&
tracerPaths.map((path, idx) => (
<path
key={`debug-path-${idx}`}
d={path}
fill="none"
stroke="red"
strokeWidth={3}
strokeDasharray="10,5"
vectorEffect="non-scaling-stroke"
opacity={0.8}
/>
))}
<defs>
{/* Glow filter for the main flame */}
<filter id="flame-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation={tracerSize * 1.2} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Smaller glow for sparks */}
<filter id="spark-glow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation={tracerSize * 0.5} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Fire gradient - hot core to cooler edges */}
<radialGradient id="flame-gradient">
<stop offset="0%" stopColor="#ffffff" stopOpacity="1" />
<stop offset="20%" stopColor="#fffde7" stopOpacity="1" />
<stop offset="40%" stopColor="#ffd54f" stopOpacity="0.95" />
<stop offset="60%" stopColor="#ff9800" stopOpacity="0.85" />
<stop offset="80%" stopColor="#f44336" stopOpacity="0.6" />
<stop offset="100%" stopColor="#d32f2f" stopOpacity="0" />
</radialGradient>
{/* Ember gradient for trailing sparks */}
<radialGradient id="ember-gradient">
<stop offset="0%" stopColor="#ffeb3b" stopOpacity="1" />
<stop offset="50%" stopColor="#ff9800" stopOpacity="0.8" />
<stop offset="100%" stopColor="#ff5722" stopOpacity="0" />
</radialGradient>
</defs>
{/* Region fill and stroke */}
<path
id="region-outline-path"
d={displayRegionPath}
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'}
stroke={isDark ? '#3b82f6' : '#2563eb'}
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
{/* Tracer animation groups - one for each island/segment, all animate simultaneously */}
{tracerPaths.map((_, pathIdx) => (
<animated.g
key={`tracer-${confirmedLetterCount}-${pathIdx}`}
opacity={tracerSpring.overallOpacity}
>
{/* Outer fire glow - size scales with intensity */}
<animated.circle
r={tracerSpring.sizeScale.to((s) => tracerSize * 1.5 * s)}
fill="url(#flame-gradient)"
filter="url(#flame-glow)"
opacity={tracerSpring.glowScale.to((g) => 0.5 + g * 0.2)}
>
<animateMotion dur={`${tracerDuration}s`} repeatCount="indefinite">
<mpath href={`#tracer-motion-path-${pathIdx}`} />
</animateMotion>
</animated.circle>
{/* Main flame body */}
<animated.circle
r={tracerSpring.sizeScale.to((s) => tracerSize * s)}
fill="url(#flame-gradient)"
filter="url(#flame-glow)"
>
<animateMotion dur={`${tracerDuration}s`} repeatCount="indefinite">
<mpath href={`#tracer-motion-path-${pathIdx}`} />
</animateMotion>
</animated.circle>
{/* Hot white core - gets more intense/bright as letters progress */}
<animated.circle
r={tracerSpring.sizeScale.to((s) => tracerSize * 0.35 * (2 - s))}
fill="white"
>
<animateMotion dur={`${tracerDuration}s`} repeatCount="indefinite">
<mpath href={`#tracer-motion-path-${pathIdx}`} />
</animateMotion>
<animate
attributeName="opacity"
values="1;0.85;1;0.9;1"
dur="0.15s"
repeatCount="indefinite"
/>
</animated.circle>
{/* Trailing embers - exponentially increasing count (no filter for performance) */}
{Array.from({ length: emberCount }, (_, i) => {
const offset = (i + 1) / (emberCount + 1) // Spread evenly behind the main flame
return (
<animated.circle
key={`ember-${pathIdx}-${i}`}
r={tracerSpring.emberScale.to(
(e) => tracerSize * (0.6 - (i / emberCount) * 0.35) * e
)}
fill="url(#ember-gradient)"
>
<animateMotion
dur={`${tracerDuration}s`}
repeatCount="indefinite"
begin={`-${offset * tracerDuration * 0.4}s`}
>
<mpath href={`#tracer-motion-path-${pathIdx}`} />
</animateMotion>
<animate
attributeName="opacity"
values="0.9;0.6;0.3;0.1"
dur="0.4s"
repeatCount="indefinite"
/>
</animated.circle>
)
})}
{/* Flying sparks - exponentially increasing count (no filter for performance) */}
{Array.from({ length: sparkCount }, (_, i) => {
const startOffset = i / sparkCount // Distribute evenly around the path
return (
<circle
key={`spark-${pathIdx}-${i}`}
r={tracerSize * 0.15}
fill="#ffeb3b"
>
<animateMotion
dur={`${tracerDuration}s`}
repeatCount="indefinite"
begin={`${startOffset * tracerDuration}s`}
>
<mpath href={`#tracer-motion-path-${pathIdx}`} />
</animateMotion>
<animate
attributeName="opacity"
values="1;0.8;0.3;0"
dur="0.5s"
repeatCount="indefinite"
begin={`${startOffset * tracerDuration}s`}
/>
</circle>
)
})}
</animated.g>
))}
</svg>
)
})()}

View File

@ -260,6 +260,9 @@ importers:
remark-html:
specifier: ^16.0.1
version: 16.0.1
simplify-js:
specifier: ^1.2.4
version: 1.2.4
socket.io:
specifier: ^4.8.1
version: 4.8.1
@ -9019,6 +9022,9 @@ packages:
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simplify-js@1.2.4:
resolution: {integrity: sha512-vITfSlwt7h/oyrU42R83mtzFpwYk3+mkH9bOHqq/Qw6n8rtR7aE3NZQ5fbcyCUVVmuMJR6ynsAhOfK2qoah8Jg==}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
@ -20286,6 +20292,8 @@ snapshots:
once: 1.4.0
simple-concat: 1.0.1
simplify-js@1.2.4: {}
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29