From ad0cf2e9ccff79d5d3fe7425f1cb69623a93d005 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sun, 5 Apr 2026 13:10:11 +0200 Subject: [PATCH] Add translations and update "Whack-A-Bug" game with localization support, refined UI, and improved game mechanics --- common/messages/de.json | 16 ++++- common/messages/fr.json | 16 ++++- web/components/error-boundary.tsx | 4 ++ web/components/game/whack-a-bug.tsx | 100 +++++++++++++++++++++------- web/pages/whack-a-bug.tsx | 9 ++- 5 files changed, 118 insertions(+), 27 deletions(-) diff --git a/common/messages/de.json b/common/messages/de.json index 52693a95..99f5a9c4 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -1358,5 +1358,19 @@ "vote.toast.created": "Vorschlag erstellt", "vote.urgent": "Dringend", "vote.voted": "Stimme gespeichert", - "vote.with_priority": "mit Priorität" + "vote.with_priority": "mit Priorität", + "game.whackabug.title": "WHACK-A-BUG", + "game.whackabug.subtitle": "// die Schuld für den Fehler", + "game.whackabug.instructions_lives": "Du hast {lives} Leben. Entkommende Bugs = Leben verloren.", + "game.whackabug.instructions_time": "{seconds} Sekunden. Zerstöre so viele wie möglich.", + "game.whackabug.victory_title": "// SIEG — Zeit abgelaufen", + "game.whackabug.victory_message": "Du findest immer noch Bugs in freier Wildbahn? Kontaktiere uns und wir werden sie aufspüren.", + "game.whackabug.play_again": "NOCHMAL SPIELEN", + "game.whackabug.gameover_title": "// GAME OVER — alle Bugs entkommen", + "game.whackabug.gameover_message": "Wenn die Bugs zu viel werden, kontaktiere uns und wir erledigen sie für dich.", + "game.whackabug.retry": "WIEDERHOLEN", + "game.whackabug.start_game": "SPIEL STARTEN", + "game.whackabug.time": "ZEIT", + "game.whackabug.seo_title": "Whack-A-Bug", + "game.whackabug.seo_description": "Schiebe die Schuld für den Fehler auf die Bugs - ein lustiges Spiel" } diff --git a/common/messages/fr.json b/common/messages/fr.json index 1f811188..cc73424c 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -1357,5 +1357,19 @@ "vote.toast.created": "Proposition créée", "vote.urgent": "Urgente", "vote.voted": "Vote enregistré", - "vote.with_priority": "avec priorité" + "vote.with_priority": "avec priorité", + "game.whackabug.title": "WHACK-A-BUG", + "game.whackabug.subtitle": "// blâmez les bugs pour l'erreur", + "game.whackabug.instructions_lives": "Vous avez {lives} vies. Bugs qui s'échappent = vie perdue.", + "game.whackabug.instructions_time": "{seconds} secondes. Écrasez-en autant que possible.", + "game.whackabug.victory_title": "// VICTOIRE — le temps est écoulé", + "game.whackabug.victory_message": "Vous trouvez toujours des bugs dans la nature? Contactez-nous et nous les traquerons.", + "game.whackabug.play_again": "REJOUER", + "game.whackabug.gameover_title": "// GAME OVER — tous les bugs se sont échappés", + "game.whackabug.gameover_message": "Si les bugs sont trop difficiles à gérer, contactez-nous et nous les écraserons pour vous.", + "game.whackabug.retry": "RÉESSAYER", + "game.whackabug.start_game": "DÉMARRER", + "game.whackabug.time": "TEMPS", + "game.whackabug.seo_title": "Whack-A-Bug", + "game.whackabug.seo_description": "Blâmez les bugs pour l'erreur - un jeu amusant" } diff --git a/web/components/error-boundary.tsx b/web/components/error-boundary.tsx index 8da90916..77e7a940 100644 --- a/web/components/error-boundary.tsx +++ b/web/components/error-boundary.tsx @@ -1,5 +1,6 @@ import {Component, ErrorInfo, ReactNode} from 'react' import {Button} from 'web/components/buttons/button' +import WhackABug from 'web/components/game/whack-a-bug' interface Props { children?: ReactNode @@ -61,6 +62,9 @@ export class ErrorBoundary extends Component { Go home +
+ +
) diff --git a/web/components/game/whack-a-bug.tsx b/web/components/game/whack-a-bug.tsx index 39f5f6d2..962d8cc9 100644 --- a/web/components/game/whack-a-bug.tsx +++ b/web/components/game/whack-a-bug.tsx @@ -2,10 +2,11 @@ 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 = 30 +const GAME_DURATION = 20 const BUG_TYPES = [ {icon: '🐛', label: 'worm', duration: 1600}, @@ -72,7 +73,7 @@ const css = ` align-items: center; width: 100%; max-width: 380px; - background: #0d170d; + background: canvas-100; border: 1px solid #1f3a1f; border-radius: 8px; padding: 8px 16px; @@ -85,7 +86,7 @@ const css = ` .wab-timer-bar-wrap { width: 100%; max-width: 380px; - background: #0d170d; + background: canvas-100; border: 1px solid #1f3a1f; border-radius: 4px; height: 6px; @@ -109,7 +110,7 @@ const css = ` .wab-hole { aspect-ratio: 1; - background: #050c05; + background: canvas-100; border: 2px solid #1a2e1a; border-radius: 50%; display: flex; @@ -222,6 +223,7 @@ const css = ` ` export default function WhackABug() { + const t = useT() const [gameState, setGameState] = useState('idle') const [lives, setLives] = useState(MAX_LIVES) const [timeLeft, setTimeLeft] = useState(GAME_DURATION) @@ -233,6 +235,9 @@ export default function WhackABug() { 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)) @@ -248,11 +253,20 @@ export default function WhackABug() { 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]) { @@ -260,26 +274,24 @@ export default function WhackABug() { return prev } const next = [...prev] - next[index] = {type, id: Date.now() + Math.random(), whacked: false} + next[index] = {type, id: bugId, whacked: false} return next }) const disappear = setTimeout(() => { if (gameRef.current !== 'playing') return - let wasWhacked = false + // 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].whacked) { - wasWhacked = true - return prev - } + if (!prev[index] || prev[index].id !== bugId) return prev const next = [...prev] next[index] = null return next }) - if (wasWhacked) return - livesRef.current -= 1 setLives(livesRef.current) setEffects((prev) => [...prev, {id: Date.now(), index, type: 'miss'}]) @@ -299,6 +311,7 @@ export default function WhackABug() { clearAll() livesRef.current = MAX_LIVES gameRef.current = 'playing' + whackedIdsRef.current.clear() setLives(MAX_LIVES) setTimeLeft(GAME_DURATION) setHoles(Array(GRID_SIZE).fill(null)) @@ -308,7 +321,7 @@ export default function WhackABug() { timerRef.current = setInterval(() => { setTimeLeft((t) => { if (t <= 1) { - endGame() + wonGame() return 0 } return t - 1 @@ -316,13 +329,15 @@ export default function WhackABug() { }, 1000) spawnRef.current = setTimeout(spawnBug, 400) - }, [clearAll, endGame, spawnBug]) + }, [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(() => { @@ -353,6 +368,12 @@ export default function WhackABug() { }) }, [effects]) + useEffect(() => { + setTimeout(() => { + startGame() + }, 5000) + }, []) + const pct = (timeLeft / GAME_DURATION) * 100 const barClass = pct <= 20 ? 'crit' : pct <= 40 ? 'warn' : '' @@ -360,8 +381,10 @@ export default function WhackABug() { <>
-

WHACK-A-BUG

-

// blame the bugs for the error

+

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

+

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

{
@@ -373,23 +396,54 @@ export default function WhackABug() { ))}

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

} - {gameState === 'gameover' ? ( + {gameState === 'won' ? (
-

// GAME OVER — all bugs escaped

+

+ {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.", + )} +

+
) : ( )} @@ -402,7 +456,7 @@ export default function WhackABug() { ))}
- TIME + {t('game.whackabug.time', 'TIME')}