'use client' import {debug} from 'common/logger' import {useCallback, useEffect, useRef, useState} from 'react' import {useT} from 'web/lib/locale' const GRID_SIZE = 9 const MAX_LIVES = 3 const GAME_DURATION = 20 const BUG_TYPES = [ {icon: '🐛', label: 'worm', duration: 1600}, {icon: '🪲', label: 'beetle', duration: 1200}, {icon: '🦟', label: 'mozzie', duration: 800}, ] function Heart({filled}: {filled: any}) { return ( ❤️ ) } const css = ` .wab-wrap * { box-sizing: border-box; } .wab-wrap { font-family: 'Share Tech Mono', monospace; min-height: 420px; border-radius: 16px; border: 1px solid #1a2e1a; padding: 28px 24px 32px; display: flex; flex-direction: column; align-items: center; gap: 20px; position: relative; overflow: hidden; } .wab-wrap::before { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient( 0deg, transparent, transparent 2px, rgba(57,255,20,0.018) 2px, rgba(57,255,20,0.018) 4px ); pointer-events: none; border-radius: 16px; } .wab-title { font-family: 'VT323', monospace; font-size: 36px; letter-spacing: 2px; text-shadow: 0 0 8px #39ff1466; margin: 0; line-height: 1; } .wab-subtitle { font-size: 13px; margin: -12px 0 0; letter-spacing: 1px; } .wab-hud { display: flex; justify-content: space-between; align-items: center; width: 100%; max-width: 380px; background: canvas-100; border: 1px solid #1f3a1f; border-radius: 8px; padding: 8px 16px; font-size: 14px; } .wab-hud-label { font-size: 11px; letter-spacing: 1px; display: block; } .wab-hud-val { font-size: 18px; font-family: 'VT323', monospace; } .wab-timer-bar-wrap { width: 100%; max-width: 380px; background: canvas-100; border: 1px solid #1f3a1f; border-radius: 4px; height: 6px; overflow: hidden; } .wab-timer-bar { height: 100%; transition: width 1s linear, background 0.3s; border-radius: 4px; } .wab-timer-bar.warn { background: #ffcc00; } .wab-timer-bar.crit { background: #ff4444; } .wab-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; max-width: 380px; width: 100%; } .wab-hole { aspect-ratio: 1; background: canvas-100; border: 2px solid #1a2e1a; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: default; position: relative; overflow: hidden; transition: border-color 0.15s; box-shadow: inset 0 4px 12px rgba(0,0,0,0.7); } .wab-hole.has-bug { cursor: crosshair; border-color: #2a4a2a; } .wab-hole.has-bug:hover { border-color: #39ff1466; } .wab-bug { font-size: 32px; display: flex; align-items: center; justify-content: center; animation: bugPop 0.18s cubic-bezier(0.34,1.56,0.64,1) forwards; transform-origin: center bottom; user-select: none; line-height: 1; } .wab-bug.whacked { animation: bugWhack 0.22s ease-out forwards; } @keyframes bugPop { from { transform: scale(0) translateY(20px); opacity: 0; } to { transform: scale(1) translateY(0px); opacity: 1; } } @keyframes bugWhack { 0% { transform: scale(1.2) rotate(-10deg); opacity: 1; } 100% { transform: scale(0) rotate(20deg); opacity: 0; } } .wab-score-pop { position: absolute; top: 12%; left: 50%; transform: translateX(-50%); font-family: 'VT323', monospace; font-size: 22px; animation: scoreFly 0.7s ease-out forwards; pointer-events: none; white-space: nowrap; z-index: 10; } .wab-miss-flash { position: absolute; inset: 0; border-radius: 50%; background: rgba(255,60,60,0.35); animation: missFade 0.4s ease-out forwards; pointer-events: none; } @keyframes missFade { from { opacity: 1; } to { opacity: 0; } } .wab-btn { font-family: 'VT323', monospace; font-size: 20px; letter-spacing: 2px; border: none; border-radius: 6px; padding: 10px 32px; cursor: pointer; transition: transform 0.1s, box-shadow 0.1s; } .wab-btn:hover { transform: scale(1.04); box-shadow: 0 0 16px #39ff1466; } .wab-btn:active { transform: scale(0.97); } .wab-idle, .wab-gameover { display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center; padding: 8px 0; } .wab-idle p, .wab-gameover p { font-size: 11px; margin: 0; max-width: 480px; line-height: 1.6; letter-spacing: 0.5px; } .wab-legend { display: flex; gap: 16px; font-size: 12px; color: #4a7a4a; } .wab-legend span { display: flex; align-items: center; gap: 4px; } .wab-lives { display: flex; gap: 4px; } ` export default function WhackABug() { const t = useT() const [gameState, setGameState] = useState('idle') const [lives, setLives] = useState(MAX_LIVES) const [timeLeft, setTimeLeft] = useState(GAME_DURATION) const [holes, setHoles] = useState(Array(GRID_SIZE).fill(null)) const [effects, setEffects] = useState<{id: number; index: number; type: string}[]>([]) const timersRef = useRef[]>([]) const timerRef = useRef | null>(null) const spawnRef = useRef | null>(null) const livesRef = useRef(MAX_LIVES) const gameRef = useRef('idle') // Tracks IDs of bugs that were successfully whacked — checked synchronously // in disappear timeouts to avoid the async state-updater mutation race. const whackedIdsRef = useRef(new Set()) const clearAll = useCallback(() => { timersRef.current.forEach((t) => clearTimeout(t)) timersRef.current = [] if (timerRef.current) clearInterval(timerRef.current) if (spawnRef.current) clearTimeout(spawnRef.current) }, []) const endGame = useCallback(() => { gameRef.current = 'gameover' setGameState('gameover') clearAll() setHoles(Array(GRID_SIZE).fill(null)) }, [clearAll]) const wonGame = useCallback(() => { gameRef.current = 'won' setGameState('won') clearAll() setHoles(Array(GRID_SIZE).fill(null)) }, [clearAll]) const spawnBug = useCallback(() => { if (gameRef.current !== 'playing') return const type = BUG_TYPES[Math.floor(Math.random() * BUG_TYPES.length)] const index = Math.floor(Math.random() * GRID_SIZE) // Stable ID created once per spawn so the disappear timeout can reference it const bugId = Date.now() + Math.random() setHoles((prev) => { if (prev[index]) { scheduleNext() return prev } const next = [...prev] next[index] = {type, id: bugId, whacked: false} return next }) const disappear = setTimeout(() => { if (gameRef.current !== 'playing') return // If this specific bug was whacked, its ID is in the set — skip penalty. if (whackedIdsRef.current.has(bugId)) return // Bug escaped: clear the hole and deduct a life. setHoles((prev) => { if (!prev[index] || prev[index].id !== bugId) return prev const next = [...prev] next[index] = null return next }) livesRef.current -= 1 setLives(livesRef.current) setEffects((prev) => [...prev, {id: Date.now(), index, type: 'miss'}]) if (livesRef.current <= 0) endGame() }, type.duration) timersRef.current.push(disappear) scheduleNext() }, [endGame]) function scheduleNext() { const delay = 700 + Math.random() * 900 spawnRef.current = setTimeout(spawnBug, delay) } const startGame = useCallback(() => { clearAll() livesRef.current = MAX_LIVES gameRef.current = 'playing' whackedIdsRef.current.clear() setLives(MAX_LIVES) setTimeLeft(GAME_DURATION) setHoles(Array(GRID_SIZE).fill(null)) setEffects([]) setGameState('playing') timerRef.current = setInterval(() => { setTimeLeft((t) => { if (t <= 1) { wonGame() return 0 } return t - 1 }) }, 1000) spawnRef.current = setTimeout(spawnBug, 400) }, [clearAll, endGame, spawnBug, wonGame]) const whack = useCallback((index: number) => { debug('Wacked') setHoles((prev) => { const hole = prev[index] if (!hole || hole.whacked) return prev // Register this bug as whacked before the disappear timeout can fire whackedIdsRef.current.add(hole.id) const next = [...prev] next[index] = {...hole, whacked: true} const t = setTimeout(() => { setHoles((h) => { const n = [...h] n[index] = null return n }) }, 220) timersRef.current.push(t) return next }) }, []) useEffect(() => { const _cleanup = (e: any) => { setEffects((prev) => prev.filter((ef) => ef.id !== e.id)) } return () => clearAll() }, [clearAll]) useEffect(() => { effects.forEach((ef) => { const t = setTimeout(() => { setEffects((prev) => prev.filter((x) => x.id !== ef.id)) }, 700) return () => clearTimeout(t) }) }, [effects]) useEffect(() => { setTimeout(() => { startGame() }, 5000) }, []) const pct = (timeLeft / GAME_DURATION) * 100 const barClass = pct <= 20 ? 'crit' : pct <= 40 ? 'warn' : '' return ( <>

{t('game.whackabug.title', 'WHACK-A-BUG')}

{t('game.whackabug.subtitle', '// blame the bugs for the error')}

{
{BUG_TYPES.map((b) => ( {b.icon} ))}

{t( 'game.whackabug.instructions_lives', 'You have {lives} lives. Bugs escape = lost life.', {lives: MAX_LIVES}, )}
{t( 'game.whackabug.instructions_time', '{seconds} seconds. Smash as many as you can.', {seconds: GAME_DURATION}, )}

} {gameState === 'won' ? (

{t('game.whackabug.victory_title', '// VICTORY — time is up')}

{t( 'game.whackabug.victory_message', "Still finding bugs in the wild? Contact us and we'll track them down.", )}

) : gameState === 'gameover' ? (

{t('game.whackabug.gameover_title', '// GAME OVER — all bugs escaped')}

{t( 'game.whackabug.gameover_message', "If the bugs are too much to handle, contact us and we'll squash them for you.", )}

) : ( )} {gameState === 'playing' && ( <>
{Array.from({length: MAX_LIVES}).map((_, i) => ( ))}
{t('game.whackabug.time', 'TIME')} {String(timeLeft).padStart(2, '0')}s
{holes.map((hole, i) => { const holeEffects = effects.filter((e) => e.index === i) return (
whack(i)} > {hole && (
{hole.type.icon}
)} {holeEffects.map((ef) => (
))}
) })}
)}
) }