diff --git a/src/apps/bridge-builder-math/App.tsx b/src/apps/bridge-builder-math/App.tsx index 0eb8cbe..1bfa9b5 100644 --- a/src/apps/bridge-builder-math/App.tsx +++ b/src/apps/bridge-builder-math/App.tsx @@ -2,17 +2,20 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { GameState, GameStatus, Problem } from './types'; import { GAME_CONFIG } from './constants'; import { generateProblem } from './services/mathEngine'; +import { audioService } from './services/audioService'; import { GameScene } from './components/GameScene'; import { ControlPanel } from './components/ControlPanel'; import { Button } from './components/Button'; import { TutorModal } from './components/TutorModal'; import { Confetti } from './components/Confetti'; -import { Play, RotateCcw, BrainCircuit, Flame } from 'lucide-react'; +import { Play, RotateCcw, BrainCircuit, Flame, Volume2, VolumeX, Clock, Trophy } from 'lucide-react'; const INITIAL_STATE: GameState = { status: GameStatus.MENU, score: 0, level: 1, + stage: 1, + timeLeft: GAME_CONFIG.TOTAL_TIME, speed: GAME_CONFIG.INITIAL_SPEED, characterX: 50, bridgeEndX: GAME_CONFIG.GAP_START, @@ -20,11 +23,33 @@ const INITIAL_STATE: GameState = { streak: 0, }; +function useMediaQuery(query: string) { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + if (media.matches !== matches) { + setMatches(media.matches); + } + const listener = () => setMatches(media.matches); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, [matches, query]); + + return matches; +} + const App: React.FC = () => { const [gameState, setGameState] = useState(INITIAL_STATE); const [currentProblem, setCurrentProblem] = useState(null); const [lastMissedProblem, setLastMissedProblem] = useState(null); const [isTutorOpen, setIsTutorOpen] = useState(false); + const [isMuted, setIsMuted] = useState(false); + const [feedback, setFeedback] = useState<{ type: 'correct' | 'wrong', id: number } | null>(null); + + // Responsive check for "Short Landscape" (Mobile Landscape) + // We want side-by-side layout here because vertical space is scarce. + const isShortLandscape = useMediaQuery('(orientation: landscape) and (max-height: 600px)'); // Celebration State const [showCelebration, setShowCelebration] = useState(false); @@ -40,6 +65,11 @@ const App: React.FC = () => { stateRef.current = gameState; }, [gameState]); + // Sync mute state + useEffect(() => { + audioService.setMuted(isMuted); + }, [isMuted]); + // Celebration Effect Hook useEffect(() => { if (gameState.streak > 0 && gameState.streak % 10 === 0) { @@ -50,14 +80,19 @@ const App: React.FC = () => { // Level Up Effect Hook useEffect(() => { if (gameState.level > 1 && (gameState.score % 100 === 0) && (gameState.score > 0)) { - // Score logic (10 points per Q) means 100 points = 10 Qs. - // Level also increments then. We prioritize streak message usually, - // but we can append or alternate. - // Since both happen at same time, let's make the text punchier. - setCelebrationText(`LEVEL ${gameState.level}!`); + triggerCelebration(`LEVEL ${gameState.level}!`); + audioService.playLevelUp(); } }, [gameState.level]); + // Stage Up Effect Hook + useEffect(() => { + if (gameState.stage > 1) { // Only celebrate on stage 2, 3, 4 + triggerCelebration(`STAGE ${gameState.stage}!`); + audioService.playStageComplete(); + } + }, [gameState.stage]); + const triggerCelebration = (text: string) => { setCelebrationText(text); setShowCelebration(true); @@ -65,6 +100,7 @@ const App: React.FC = () => { }; const startGame = () => { + audioService.init(); const startState = { ...INITIAL_STATE, status: GameStatus.PLAYING, @@ -81,6 +117,24 @@ const App: React.FC = () => { const deltaTime = time - (lastTimeRef.current || time); lastTimeRef.current = time; + // Timer Logic + const newTimeLeft = Math.max(0, stateRef.current.timeLeft - (deltaTime / 1000)); + + if (newTimeLeft <= 0) { + handleGameOver(); + return; + } + + // Stage Logic + const elapsedTime = GAME_CONFIG.TOTAL_TIME - newTimeLeft; + // Current stage calculation: 0-5min -> stage 1, 5-10min -> stage 2, etc. + const calculatedStage = Math.floor(elapsedTime / GAME_CONFIG.STAGE_DURATION) + 1; + // Cap stage at 4 + const cappedStage = Math.min(4, calculatedStage); + + // Only update if stage changed AND it's a valid new stage (e.g. stage 1 -> stage 2) + const stageChanged = cappedStage > stateRef.current.stage; + // Movement Logic // Adjust speed based on delta time (normalize to 60fps ~ 16.6ms) const moveAmount = stateRef.current.speed * (deltaTime / 16.6); @@ -96,6 +150,8 @@ const App: React.FC = () => { setGameState(prev => ({ ...prev, + timeLeft: newTimeLeft, + stage: stageChanged ? cappedStage : prev.stage, characterX: newCharacterX })); @@ -104,6 +160,7 @@ const App: React.FC = () => { const handleGameOver = () => { if (requestRef.current) cancelAnimationFrame(requestRef.current); + audioService.playGameOver(); // Save the problem they failed on (the current one they couldn't solve in time) if (currentProblem) { @@ -113,13 +170,34 @@ const App: React.FC = () => { setGameState(prev => ({ ...prev, status: GameStatus.GAME_OVER, streak: 0 })); }; + const handleVictory = () => { + if (requestRef.current) cancelAnimationFrame(requestRef.current); + audioService.playVictory(); + triggerCelebration("VICTORY!"); + setGameState(prev => ({ ...prev, status: GameStatus.VICTORY })); + }; + const handleAnswer = useCallback((answer: number) => { if (!currentProblem) return; if (answer === currentProblem.answer) { // Correct! + audioService.playCorrect(); + setFeedback({ type: 'correct', id: Date.now() }); + setGameState(prev => { const newScore = prev.score + 10; + + // Check Victory + if (newScore >= GAME_CONFIG.WIN_SCORE) { + // We need to defer this slightly or handle it outside the state update + // But we can just return the state and call a function in useEffect or here if we weren't in a callback + // Since we are in a callback, let's trigger it immediately after update + setTimeout(handleVictory, 0); + // Important: if victory, do not update level, speed, etc. as game will end + return { ...prev, score: newScore }; + } + const newLevel = Math.floor(newScore / 100) + 1; // Speed scaling: Base + (Level-1 * Increment) @@ -136,12 +214,15 @@ const App: React.FC = () => { }); // Determine difficulty based on level - const currentLevel = Math.floor((stateRef.current.score + 10) / 100) + 1; - const forceRegrouping = currentLevel >= 3 && Math.random() > 0.4; - setCurrentProblem(generateProblem(currentLevel, forceRegrouping)); + // Using stateRef.current for latest score for problem generation + const currentLevelForProblem = Math.floor((stateRef.current.score + 10) / 100) + 1; + const forceRegrouping = currentLevelForProblem >= 3 && Math.random() > 0.4; + setCurrentProblem(generateProblem(currentLevelForProblem, forceRegrouping)); } else { // Incorrect + audioService.playIncorrect(); + setFeedback({ type: 'wrong', id: Date.now() }); setGameState(prev => ({ ...prev, streak: 0 })); // Visual feedback logic could go here (e.g. screen shake), handled by UI via streak reset } @@ -154,18 +235,40 @@ const App: React.FC = () => { }; }, []); + // Format Time + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; + }; + return (
{/* HUD */} -
-
- 🏗️ Bridge Builder +
+
+ 🏗️ Bridge Builder
- {gameState.status === GameStatus.PLAYING && ( -
+
+ {gameState.status === GameStatus.PLAYING && ( +
+ {/* Timer */} +
+ Time + + {formatTime(gameState.timeLeft)} + +
+ + {/* Stage */} +
+ Stage + {gameState.stage}/4 +
+ {/* Streak Indicator */} -
5 ? 'text-orange-500 scale-110' : ''}`}> +
5 ? 'text-orange-500 scale-110' : ''}`}> Streak {gameState.streak} 0 ? "fill-orange-500 text-orange-500" : "text-slate-300"} /> @@ -173,26 +276,35 @@ const App: React.FC = () => {
- Score - {gameState.score} + Score + {gameState.score}/{GAME_CONFIG.WIN_SCORE}
-
+
Level {gameState.level}
- )} + )} + + +
{/* Main Game Area */} -
+
{/* Game Viewport On Mobile: flex-grow but with a small min-height to allow controls to take space. On Desktop: Standard behavior. + On Short Landscape: Takes remaining width. */} -
- +
+ {/* Celebration Overlay */} {showCelebration && ( @@ -207,49 +319,80 @@ const App: React.FC = () => { )} {/* Overlays */} - {gameState.status === GameStatus.MENU && ( -
-

Ready to Build?

-

- Solve math problems to build the bridge before the engineer falls! -

- -
- )} - - {gameState.status === GameStatus.GAME_OVER && ( -
-
-

Oh no!

-

Final Score: {gameState.score}

- -
- - - {lastMissedProblem && ( - - )}
-
-
+ )} + + {gameState.status === GameStatus.VICTORY && ( +
+
+ +

VICTORY!

+

+ You built the bridge!
+ Score: {gameState.score} • Time Left: {formatTime(gameState.timeLeft)} +

+ + +
+
+ )} + + {gameState.status === GameStatus.GAME_OVER && ( +
+
+

Oh no!

+

+ {gameState.timeLeft <= 0 ? "Time's Up!" : "You Fell!"} +

+

Final Score: {gameState.score}

+ +
+ + + {lastMissedProblem && ( + + )} +
+
+
+ )} + )}
{/* Controls On Mobile: Takes ~50% of vertical space or 400px, whichever is less/more suitable. On Desktop: Fixed height 16rem (256px). + On Short Landscape: Fixed width side panel. */} -
+
@@ -265,5 +408,4 @@ const App: React.FC = () => {
); }; - export default App; \ No newline at end of file diff --git a/src/apps/bridge-builder-math/components/ControlPanel.tsx b/src/apps/bridge-builder-math/components/ControlPanel.tsx index 732d17d..0938291 100644 --- a/src/apps/bridge-builder-math/components/ControlPanel.tsx +++ b/src/apps/bridge-builder-math/components/ControlPanel.tsx @@ -6,9 +6,10 @@ interface ControlPanelProps { problem: Problem | null; status: GameStatus; onAnswer: (answer: number) => void; + forceVertical?: boolean; } -export const ControlPanel: React.FC = ({ problem, status, onAnswer }) => { +export const ControlPanel: React.FC = ({ problem, status, onAnswer, forceVertical = false }) => { const [input, setInput] = useState(''); // Clear input when problem changes @@ -40,9 +41,9 @@ export const ControlPanel: React.FC = ({ problem, status, onA const btnClass = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 font-bold text-2xl md:text-3xl rounded-lg shadow-sm border-b-4 border-slate-300 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"; return ( -
- {/* Question Display - Smaller on mobile */} -
+
+ {/* Question Display - Smaller on mobile/vertical */} +
Build Segment
{problem.a} @@ -55,8 +56,8 @@ export const ControlPanel: React.FC = ({ problem, status, onA
- {/* Numpad - Takes more space on mobile */} -
+ {/* Numpad - Takes more space on mobile/vertical */} +
{/* Row 1: 1, 2, 3 */} {[1, 2, 3].map((num) => ( diff --git a/src/apps/bridge-builder-math/components/GameScene.tsx b/src/apps/bridge-builder-math/components/GameScene.tsx index feb99e6..b43fd0a 100644 --- a/src/apps/bridge-builder-math/components/GameScene.tsx +++ b/src/apps/bridge-builder-math/components/GameScene.tsx @@ -1,22 +1,98 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { GameState } from '../types'; import { GAME_CONFIG, COLORS } from '../constants'; import { HardHat, Mountain } from 'lucide-react'; interface GameSceneProps { gameState: GameState; + feedback?: { type: 'correct' | 'wrong', id: number } | null; } -export const GameScene: React.FC = ({ gameState }) => { +interface FloatingText { + id: number; + x: number; + y: number; + text: string; + color: string; +} + +export const GameScene: React.FC = ({ gameState, feedback }) => { const { characterX, bridgeEndX } = gameState; + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(GAME_CONFIG.CANVAS_WIDTH); + const [containerHeight, setContainerHeight] = useState(GAME_CONFIG.CANVAS_HEIGHT); - // Calculate view offset to keep character somewhat centered but moving right - // We want the character to be at roughly 30% of the screen width initially - // As character moves, we scroll the 'camera' - const viewOffset = Math.max(0, characterX - GAME_CONFIG.CANVAS_WIDTH * 0.3); + const [isShaking, setIsShaking] = useState(false); + const [floatingTexts, setFloatingTexts] = useState([]); + + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // Handle Feedback + useEffect(() => { + if (!feedback) return; + + // Shake on error + if (feedback.type === 'wrong') { + setIsShaking(true); + setTimeout(() => setIsShaking(false), 500); + } + + // Add Floating Text + const newText: FloatingText = { + id: feedback.id, + x: characterX, + y: (containerHeight * 0.65) - 60, // Above character + text: feedback.type === 'correct' ? '+10' : '❌', + color: feedback.type === 'correct' ? 'text-green-500' : 'text-red-500' + }; + + setFloatingTexts(prev => [...prev, newText]); + + // Cleanup text after animation + setTimeout(() => { + setFloatingTexts(prev => prev.filter(ft => ft.id !== feedback.id)); + }, 1000); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [feedback]); + + // Dynamic positioning + const viewOffset = Math.max(0, characterX - containerWidth * 0.3); + // Place bridge at ~65% of screen height, but clamp it reasonable + const bridgeY = containerHeight * 0.65; return ( -
+
+ + {/* Background Decor */}
@@ -38,7 +114,7 @@ export const GameScene: React.FC = ({ gameState }) => { className="absolute border-r-4 border-amber-900" style={{ left: 0, - top: GAME_CONFIG.BRIDGE_Y, + top: bridgeY, width: GAME_CONFIG.GAP_START, height: '200vh', // Extend deep to cover tall screens (foldables) backgroundColor: COLORS.grass, @@ -54,7 +130,7 @@ export const GameScene: React.FC = ({ gameState }) => { className="absolute w-[9999px]" style={{ left: GAME_CONFIG.GAP_START, - top: GAME_CONFIG.BRIDGE_Y, + top: bridgeY, height: '200vh', // Extend deep backgroundColor: 'transparent', }} @@ -65,7 +141,7 @@ export const GameScene: React.FC = ({ gameState }) => { className="absolute h-4 shadow-lg border-y-2 border-amber-900" style={{ left: GAME_CONFIG.GAP_START, - top: GAME_CONFIG.BRIDGE_Y, + top: bridgeY, width: Math.max(0, bridgeEndX - GAME_CONFIG.GAP_START), backgroundColor: COLORS.bridge, backgroundImage: `repeating-linear-gradient(90deg, transparent, transparent 18px, ${COLORS.bridgeDark} 18px, ${COLORS.bridgeDark} 20px)`, @@ -77,7 +153,7 @@ export const GameScene: React.FC = ({ gameState }) => { className="absolute transition-transform duration-75 flex flex-col items-center justify-center" style={{ left: characterX, - top: GAME_CONFIG.BRIDGE_Y - GAME_CONFIG.CHARACTER_SIZE, + top: bridgeY - GAME_CONFIG.CHARACTER_SIZE, width: GAME_CONFIG.CHARACTER_SIZE, height: GAME_CONFIG.CHARACTER_SIZE, transform: `translateY(${gameState.status === 'GAME_OVER' && characterX > bridgeEndX ? '200px' : '0px'}) rotate(${gameState.status === 'GAME_OVER' && characterX > bridgeEndX ? '90deg' : '0deg'})`, @@ -98,7 +174,7 @@ export const GameScene: React.FC = ({ gameState }) => { className="absolute border-l-4 border-amber-900 flex items-center justify-center text-white/50 font-bold text-4xl" style={{ left: bridgeEndX + 300, // Always ahead - top: GAME_CONFIG.BRIDGE_Y + 50, + top: bridgeY + 50, width: 100, height: '200vh', // Extend deep backgroundColor: COLORS.grass, @@ -106,7 +182,20 @@ export const GameScene: React.FC = ({ gameState }) => { }} > GOAL → -
+
+ + {/* Floating Texts */} + {floatingTexts.map(ft => ( +
+ {ft.text} +
+ ))} + +
); }; \ No newline at end of file diff --git a/src/apps/bridge-builder-math/constants.ts b/src/apps/bridge-builder-math/constants.ts index 739664e..2e27d11 100644 --- a/src/apps/bridge-builder-math/constants.ts +++ b/src/apps/bridge-builder-math/constants.ts @@ -8,6 +8,9 @@ export const GAME_CONFIG = { SPEED_INCREMENT: 0.05, // 5% speed increase per level MAX_SPEED: 1.0, GAP_START: 200, // Increased start buffer + TOTAL_TIME: 1200, // 20 minutes in seconds + STAGE_DURATION: 300, // 5 minutes in seconds + WIN_SCORE: 700, // 70 questions * 10 points }; export const COLORS = { diff --git a/src/apps/bridge-builder-math/index.html b/src/apps/bridge-builder-math/index.html index f35585b..7fdc734 100644 --- a/src/apps/bridge-builder-math/index.html +++ b/src/apps/bridge-builder-math/index.html @@ -30,7 +30,6 @@ } } -
diff --git a/src/apps/bridge-builder-math/services/audioService.ts b/src/apps/bridge-builder-math/services/audioService.ts new file mode 100644 index 0000000..1c05c82 --- /dev/null +++ b/src/apps/bridge-builder-math/services/audioService.ts @@ -0,0 +1,199 @@ +class AudioService { + private ctx: AudioContext | null = null; + private isMuted: boolean = false; + + private getContext(): AudioContext { + if (!this.ctx) { + // @ts-ignore - Handle webkit prefix if needed, though modern browsers are fine + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContextClass(); + } + return this.ctx; + } + + // Must be called on user interaction + public async init() { + const ctx = this.getContext(); + if (ctx.state === 'suspended') { + await ctx.resume(); + } + } + + public setMuted(muted: boolean) { + this.isMuted = muted; + } + + public playCorrect() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // Nice "Ding" - Sine wave with high pitch and quick decay + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'sine'; + osc.frequency.setValueAtTime(523.25, t); // C5 + osc.frequency.exponentialRampToValueAtTime(1046.5, t + 0.1); // C6 + + gain.gain.setValueAtTime(0.3, t); + gain.gain.exponentialRampToValueAtTime(0.01, t + 0.5); + + osc.start(t); + osc.stop(t + 0.5); + + // Add a second harmonic for richness + const osc2 = ctx.createOscillator(); + const gain2 = ctx.createGain(); + osc2.connect(gain2); + gain2.connect(ctx.destination); + osc2.type = 'triangle'; + osc2.frequency.setValueAtTime(659.25, t); // E5 + gain2.gain.setValueAtTime(0.1, t); + gain2.gain.exponentialRampToValueAtTime(0.01, t + 0.4); + osc2.start(t); + osc2.stop(t + 0.4); + } + + public playIncorrect() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // "Buzz/Thud" - Sawtooth with low pitch + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(150, t); + osc.frequency.linearRampToValueAtTime(100, t + 0.3); + + gain.gain.setValueAtTime(0.3, t); + gain.gain.linearRampToValueAtTime(0.01, t + 0.3); + + osc.start(t); + osc.stop(t + 0.3); + } + + public playLevelUp() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // Arpeggio C Major + const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 + + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'square'; + osc.frequency.value = freq; + + const startTime = t + (i * 0.1); + gain.gain.setValueAtTime(0.1, startTime); + gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4); + + osc.start(startTime); + osc.stop(startTime + 0.4); + }); + } + + public playStageComplete() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // Fanfare - Triads + const chords = [ + [523.25, 659.25, 783.99], // C Major + [587.33, 739.99, 880.00], // D Major + [659.25, 830.61, 987.77], // E Major + ]; + + chords.forEach((chord, i) => { + const startTime = t + (i * 0.2); + chord.forEach(freq => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'sawtooth'; + osc.frequency.value = freq; + + gain.gain.setValueAtTime(0.08, startTime); + gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.6); + + osc.start(startTime); + osc.stop(startTime + 0.6); + }); + }); + } + + public playVictory() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // Final Fantasy Victory Fanfare-ish + const notes = [ + 523.25, 523.25, 523.25, 523.25, 415.30, 466.16, 523.25, 466.16, 523.25 + ]; + const timings = [ + 0, 0.15, 0.30, 0.45, 0.90, 1.35, 1.80, 2.15, 2.25 + ]; + + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'square'; + osc.frequency.value = freq; + + const startTime = t + timings[i]; + const duration = i === notes.length - 1 ? 2.0 : 0.12; + + gain.gain.setValueAtTime(0.1, startTime); + gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration); + + osc.start(startTime); + osc.stop(startTime + duration); + }); + } + + public playGameOver() { + if (this.isMuted) return; + const ctx = this.getContext(); + const t = ctx.currentTime; + + // Sliding down pitch + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + + osc.connect(gain); + gain.connect(ctx.destination); + + osc.type = 'triangle'; + osc.frequency.setValueAtTime(400, t); + osc.frequency.linearRampToValueAtTime(100, t + 1.0); + + gain.gain.setValueAtTime(0.3, t); + gain.gain.linearRampToValueAtTime(0.01, t + 1.0); + + osc.start(t); + osc.stop(t + 1.0); + } +} + +export const audioService = new AudioService(); diff --git a/src/apps/bridge-builder-math/types.ts b/src/apps/bridge-builder-math/types.ts index 946e927..1bd4c4f 100644 --- a/src/apps/bridge-builder-math/types.ts +++ b/src/apps/bridge-builder-math/types.ts @@ -13,12 +13,15 @@ export enum GameStatus { MENU = 'MENU', PLAYING = 'PLAYING', GAME_OVER = 'GAME_OVER', + VICTORY = 'VICTORY', } export interface GameState { status: GameStatus; score: number; level: number; + stage: number; + timeLeft: number; // in seconds speed: number; // pixels per frame characterX: number; // current position bridgeEndX: number; // how far the bridge is built diff --git a/src/apps/bridge-builder-math/vite.config.ts b/src/apps/bridge-builder-math/vite.config.ts index 950e2e2..a03c6ed 100644 --- a/src/apps/bridge-builder-math/vite.config.ts +++ b/src/apps/bridge-builder-math/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => { server: { port: 3000, host: '0.0.0.0', + allowedHosts: ["delu.local", "delu.home"] }, plugins: [react()], define: {