22
33import { debug } from 'common/logger'
44import { useCallback , useEffect , useRef , useState } from 'react'
5+ import { useT } from 'web/lib/locale'
56
67const GRID_SIZE = 9
78const MAX_LIVES = 3
8- const GAME_DURATION = 30
9+ const GAME_DURATION = 20
910
1011const BUG_TYPES = [
1112 { icon : '🐛' , label : 'worm' , duration : 1600 } ,
@@ -72,7 +73,7 @@ const css = `
7273 align-items: center;
7374 width: 100%;
7475 max-width: 380px;
75- background: #0d170d ;
76+ background: canvas-100 ;
7677 border: 1px solid #1f3a1f;
7778 border-radius: 8px;
7879 padding: 8px 16px;
@@ -85,7 +86,7 @@ const css = `
8586 .wab-timer-bar-wrap {
8687 width: 100%;
8788 max-width: 380px;
88- background: #0d170d ;
89+ background: canvas-100 ;
8990 border: 1px solid #1f3a1f;
9091 border-radius: 4px;
9192 height: 6px;
@@ -109,7 +110,7 @@ const css = `
109110
110111 .wab-hole {
111112 aspect-ratio: 1;
112- background: #050c05 ;
113+ background: canvas-100 ;
113114 border: 2px solid #1a2e1a;
114115 border-radius: 50%;
115116 display: flex;
@@ -222,6 +223,7 @@ const css = `
222223`
223224
224225export default function WhackABug ( ) {
226+ const t = useT ( )
225227 const [ gameState , setGameState ] = useState ( 'idle' )
226228 const [ lives , setLives ] = useState ( MAX_LIVES )
227229 const [ timeLeft , setTimeLeft ] = useState ( GAME_DURATION )
@@ -233,6 +235,9 @@ export default function WhackABug() {
233235 const spawnRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
234236 const livesRef = useRef ( MAX_LIVES )
235237 const gameRef = useRef ( 'idle' )
238+ // Tracks IDs of bugs that were successfully whacked — checked synchronously
239+ // in disappear timeouts to avoid the async state-updater mutation race.
240+ const whackedIdsRef = useRef ( new Set < number > ( ) )
236241
237242 const clearAll = useCallback ( ( ) => {
238243 timersRef . current . forEach ( ( t ) => clearTimeout ( t ) )
@@ -248,38 +253,45 @@ export default function WhackABug() {
248253 setHoles ( Array ( GRID_SIZE ) . fill ( null ) )
249254 } , [ clearAll ] )
250255
256+ const wonGame = useCallback ( ( ) => {
257+ gameRef . current = 'won'
258+ setGameState ( 'won' )
259+ clearAll ( )
260+ setHoles ( Array ( GRID_SIZE ) . fill ( null ) )
261+ } , [ clearAll ] )
262+
251263 const spawnBug = useCallback ( ( ) => {
252264 if ( gameRef . current !== 'playing' ) return
253265
254266 const type = BUG_TYPES [ Math . floor ( Math . random ( ) * BUG_TYPES . length ) ]
255267 const index = Math . floor ( Math . random ( ) * GRID_SIZE )
268+ // Stable ID created once per spawn so the disappear timeout can reference it
269+ const bugId = Date . now ( ) + Math . random ( )
256270
257271 setHoles ( ( prev ) => {
258272 if ( prev [ index ] ) {
259273 scheduleNext ( )
260274 return prev
261275 }
262276 const next = [ ...prev ]
263- next [ index ] = { type, id : Date . now ( ) + Math . random ( ) , whacked : false }
277+ next [ index ] = { type, id : bugId , whacked : false }
264278 return next
265279 } )
266280
267281 const disappear = setTimeout ( ( ) => {
268282 if ( gameRef . current !== 'playing' ) return
269283
270- let wasWhacked = false
284+ // If this specific bug was whacked, its ID is in the set — skip penalty.
285+ if ( whackedIdsRef . current . has ( bugId ) ) return
286+
287+ // Bug escaped: clear the hole and deduct a life.
271288 setHoles ( ( prev ) => {
272- if ( ! prev [ index ] || prev [ index ] . whacked ) {
273- wasWhacked = true
274- return prev
275- }
289+ if ( ! prev [ index ] || prev [ index ] . id !== bugId ) return prev
276290 const next = [ ...prev ]
277291 next [ index ] = null
278292 return next
279293 } )
280294
281- if ( wasWhacked ) return
282-
283295 livesRef . current -= 1
284296 setLives ( livesRef . current )
285297 setEffects ( ( prev ) => [ ...prev , { id : Date . now ( ) , index, type : 'miss' } ] )
@@ -299,6 +311,7 @@ export default function WhackABug() {
299311 clearAll ( )
300312 livesRef . current = MAX_LIVES
301313 gameRef . current = 'playing'
314+ whackedIdsRef . current . clear ( )
302315 setLives ( MAX_LIVES )
303316 setTimeLeft ( GAME_DURATION )
304317 setHoles ( Array ( GRID_SIZE ) . fill ( null ) )
@@ -308,21 +321,23 @@ export default function WhackABug() {
308321 timerRef . current = setInterval ( ( ) => {
309322 setTimeLeft ( ( t ) => {
310323 if ( t <= 1 ) {
311- endGame ( )
324+ wonGame ( )
312325 return 0
313326 }
314327 return t - 1
315328 } )
316329 } , 1000 )
317330
318331 spawnRef . current = setTimeout ( spawnBug , 400 )
319- } , [ clearAll , endGame , spawnBug ] )
332+ } , [ clearAll , endGame , spawnBug , wonGame ] )
320333
321334 const whack = useCallback ( ( index : number ) => {
322335 debug ( 'Wacked' )
323336 setHoles ( ( prev ) => {
324337 const hole = prev [ index ]
325338 if ( ! hole || hole . whacked ) return prev
339+ // Register this bug as whacked before the disappear timeout can fire
340+ whackedIdsRef . current . add ( hole . id )
326341 const next = [ ...prev ]
327342 next [ index ] = { ...hole , whacked : true }
328343 const t = setTimeout ( ( ) => {
@@ -353,15 +368,23 @@ export default function WhackABug() {
353368 } )
354369 } , [ effects ] )
355370
371+ useEffect ( ( ) => {
372+ setTimeout ( ( ) => {
373+ startGame ( )
374+ } , 5000 )
375+ } , [ ] )
376+
356377 const pct = ( timeLeft / GAME_DURATION ) * 100
357378 const barClass = pct <= 20 ? 'crit' : pct <= 40 ? 'warn' : ''
358379
359380 return (
360381 < >
361382 < style > { css } </ style >
362383 < div className = "wab-wrap" >
363- < h2 className = "wab-title" > WHACK-A-BUG</ h2 >
364- < p className = "wab-subtitle" > // blame the bugs for the error</ p >
384+ < h2 className = "wab-title" > { t ( 'game.whackabug.title' , 'WHACK-A-BUG' ) } </ h2 >
385+ < p className = "wab-subtitle" >
386+ { t ( 'game.whackabug.subtitle' , '// blame the bugs for the error' ) }
387+ </ p >
365388
366389 {
367390 < div className = "wab-idle" >
@@ -373,23 +396,54 @@ export default function WhackABug() {
373396 ) ) }
374397 </ div >
375398 < p className = { 'text-ink-1000/75 text-sm' } >
376- You have { MAX_LIVES } lives. Bugs escape = lost life.
399+ { t (
400+ 'game.whackabug.instructions_lives' ,
401+ 'You have {lives} lives. Bugs escape = lost life.' ,
402+ { lives : MAX_LIVES } ,
403+ ) }
377404 < br />
378- 30 seconds. Smash as many as you can.
405+ { t (
406+ 'game.whackabug.instructions_time' ,
407+ '{seconds} seconds. Smash as many as you can.' ,
408+ { seconds : GAME_DURATION } ,
409+ ) }
379410 </ p >
380411 </ div >
381412 }
382413
383- { gameState === 'gameover ' ? (
414+ { gameState === 'won ' ? (
384415 < div className = "wab-gameover" >
385- < p style = { { color : '#ff4444' , fontSize : 14 } } > // GAME OVER — all bugs escaped</ p >
416+ < p style = { { color : '#39ff14' , fontSize : 14 } } >
417+ { t ( 'game.whackabug.victory_title' , '// VICTORY — time is up' ) }
418+ </ p >
419+ < p className = { 'guidance' } >
420+ { t (
421+ 'game.whackabug.victory_message' ,
422+ "Still finding bugs in the wild? Contact us and we'll track them down." ,
423+ ) }
424+ </ p >
425+ < button className = "wab-btn" onClick = { startGame } >
426+ { t ( 'game.whackabug.play_again' , 'PLAY AGAIN' ) }
427+ </ button >
428+ </ div >
429+ ) : gameState === 'gameover' ? (
430+ < div className = "wab-gameover" >
431+ < p style = { { color : '#ff4444' , fontSize : 14 } } >
432+ { t ( 'game.whackabug.gameover_title' , '// GAME OVER — all bugs escaped' ) }
433+ </ p >
434+ < p className = { 'guidance' } >
435+ { t (
436+ 'game.whackabug.gameover_message' ,
437+ "If the bugs are too much to handle, contact us and we'll squash them for you." ,
438+ ) }
439+ </ p >
386440 < button className = "wab-btn" onClick = { startGame } >
387- RETRY
441+ { t ( 'game.whackabug.retry' , ' RETRY' ) }
388442 </ button >
389443 </ div >
390444 ) : (
391445 < button className = "wab-btn" onClick = { startGame } >
392- START GAME
446+ { t ( 'game.whackabug.start_game' , ' START GAME' ) }
393447 </ button >
394448 ) }
395449
@@ -402,7 +456,7 @@ export default function WhackABug() {
402456 ) ) }
403457 </ div >
404458 < div style = { { textAlign : 'right' } } >
405- < span className = "wab-hud-label" > TIME</ span >
459+ < span className = "wab-hud-label" > { t ( 'game.whackabug.time' , ' TIME' ) } </ span >
406460 < span
407461 className = "wab-hud-val"
408462 style = { { color : timeLeft <= 6 ? '#ff4444' : '#39ff14' } }
0 commit comments