Updates to bridge, timing

This commit is contained in:
Coffee In 2025-12-13 13:29:57 +00:00
parent 9a1ae7c0ee
commit b5266c704a
8 changed files with 510 additions and 73 deletions

View File

@ -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<GameState>(INITIAL_STATE);
const [currentProblem, setCurrentProblem] = useState<Problem | null>(null);
const [lastMissedProblem, setLastMissedProblem] = useState<Problem | null>(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 (
<div className="h-[100dvh] w-full bg-slate-100 flex flex-col font-sans overflow-hidden">
{/* HUD */}
<div className="h-16 bg-white shadow-sm flex items-center justify-between px-6 z-10 shrink-0">
<div className="font-black text-2xl text-sky-600 tracking-tighter flex items-center gap-2">
<span>🏗️</span> Bridge Builder
<div className="h-16 bg-white shadow-sm flex items-center justify-between px-4 md:px-6 z-10 shrink-0">
<div className="font-black text-xl md:text-2xl text-sky-600 tracking-tighter flex items-center gap-2">
<span>🏗️</span> <span className="hidden md:inline">Bridge Builder</span>
</div>
<div className="flex items-center gap-2 md:gap-4 flex-1 justify-end">
{gameState.status === GameStatus.PLAYING && (
<div className="flex gap-4 md:gap-8 font-bold text-slate-600">
<div className="flex gap-3 md:gap-8 font-bold text-slate-600 mr-2 md:mr-4">
{/* Timer */}
<div className={`flex flex-col items-center ${gameState.timeLeft < 60 ? 'text-red-500 animate-pulse' : ''}`}>
<span className="text-[10px] md:text-xs uppercase text-slate-400">Time</span>
<span className="text-lg md:text-xl flex items-center gap-1">
<Clock size={16} /> {formatTime(gameState.timeLeft)}
</span>
</div>
{/* Stage */}
<div className="flex flex-col items-center">
<span className="text-[10px] md:text-xs uppercase text-slate-400">Stage</span>
<span className="text-lg md:text-xl">{gameState.stage}/4</span>
</div>
{/* Streak Indicator */}
<div className={`flex flex-col items-center transition-all ${gameState.streak > 5 ? 'text-orange-500 scale-110' : ''}`}>
<div className={`hidden md:flex flex-col items-center transition-all ${gameState.streak > 5 ? 'text-orange-500 scale-110' : ''}`}>
<span className="text-xs uppercase text-slate-400">Streak</span>
<span className="text-xl flex items-center gap-1">
{gameState.streak} <Flame size={16} className={gameState.streak > 0 ? "fill-orange-500 text-orange-500" : "text-slate-300"} />
@ -173,26 +276,35 @@ const App: React.FC = () => {
</div>
<div className="flex flex-col items-center">
<span className="text-xs uppercase text-slate-400">Score</span>
<span className="text-xl">{gameState.score}</span>
<span className="text-[10px] md:text-xs uppercase text-slate-400">Score</span>
<span className="text-lg md:text-xl">{gameState.score}/{GAME_CONFIG.WIN_SCORE}</span>
</div>
<div className="flex flex-col items-center">
<div className="hidden md:flex flex-col items-center">
<span className="text-xs uppercase text-slate-400">Level</span>
<span className="text-xl">{gameState.level}</span>
</div>
</div>
)}
<button
onClick={() => setIsMuted(!isMuted)}
className="p-2 rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
</div>
{/* Main Game Area */}
<div className="flex-1 relative w-full max-w-7xl mx-auto p-2 md:p-4 flex flex-col gap-2 md:gap-4 overflow-hidden">
<div className={`flex-1 relative w-full max-w-7xl mx-auto p-2 md:p-4 flex gap-2 md:gap-4 overflow-hidden ${isShortLandscape ? 'flex-row' : 'flex-col'}`}>
{/* 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.
*/}
<div className="flex-1 w-full min-h-[200px] relative shrink">
<GameScene gameState={gameState} />
<div className={`flex-1 w-full relative shrink ${isShortLandscape ? 'h-full min-h-0' : 'min-h-[200px]'}`}>
<GameScene gameState={gameState} feedback={feedback} />
{/* Celebration Overlay */}
{showCelebration && (
@ -207,23 +319,50 @@ const App: React.FC = () => {
)}
{/* Overlays */}
{(gameState.status !== GameStatus.PLAYING && !showCelebration) && (
<>
{gameState.status === GameStatus.MENU && (
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-xl z-20">
<h1 className="text-5xl font-black text-slate-800 mb-2 text-center">Ready to Build?</h1>
<p className="text-slate-500 mb-8 text-lg max-w-md text-center">
Solve math problems to build the bridge before the engineer falls!
</p>
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex flex-col items-center justify-center rounded-xl z-20 p-4">
<h1 className="text-4xl md:text-5xl font-black text-slate-800 mb-2 text-center">Bridge Builder</h1>
<div className="text-slate-500 mb-8 text-sm md:text-lg max-w-md text-center space-y-2">
<p>Build the bridge before you fall!</p>
<div className="bg-sky-50 p-4 rounded-lg text-left text-sm grid grid-cols-2 gap-2">
<div className="flex items-center gap-2"><Clock size={16}/> 20 Minutes</div>
<div className="flex items-center gap-2"><Trophy size={16}/> 700 Points</div>
<div className="col-span-2 text-xs text-sky-600 mt-2">Finish 4 Stages to Win!</div>
</div>
</div>
<Button onClick={startGame} size="lg" className="animate-bounce">
<Play className="mr-2" /> Start Game
</Button>
</div>
)}
{gameState.status === GameStatus.VICTORY && (
<div className="absolute inset-0 bg-yellow-500/20 backdrop-blur-sm flex flex-col items-center justify-center rounded-xl z-20">
<div className="bg-white p-8 rounded-2xl shadow-2xl flex flex-col items-center animate-in zoom-in duration-300 border-4 border-yellow-400">
<Trophy size={64} className="text-yellow-500 mb-4 animate-bounce" />
<h2 className="text-4xl font-black text-yellow-500 mb-2">VICTORY!</h2>
<p className="text-slate-600 mb-6 font-bold text-xl text-center">
You built the bridge!<br/>
<span className="text-sm font-normal">Score: {gameState.score} • Time Left: {formatTime(gameState.timeLeft)}</span>
</p>
<Button onClick={startGame} variant="primary">
<Play className="mr-2" /> Play Again
</Button>
</div>
</div>
)}
{gameState.status === GameStatus.GAME_OVER && (
<div className="absolute inset-0 bg-red-500/10 backdrop-blur-sm flex flex-col items-center justify-center rounded-xl z-20">
<div className="bg-white p-8 rounded-2xl shadow-2xl flex flex-col items-center animate-in zoom-in duration-300">
<h2 className="text-4xl font-black text-red-500 mb-2">Oh no!</h2>
<p className="text-slate-600 mb-6 font-bold text-xl">Final Score: {gameState.score}</p>
<p className="text-slate-600 mb-6 font-bold text-xl">
{gameState.timeLeft <= 0 ? "Time's Up!" : "You Fell!"}
</p>
<p className="text-slate-500 mb-6">Final Score: {gameState.score}</p>
<div className="flex gap-4">
<Button onClick={startGame} variant="primary">
@ -239,17 +378,21 @@ const App: React.FC = () => {
</div>
</div>
)}
</>
)}
</div>
{/* 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.
*/}
<div className="h-[50vh] max-h-[450px] md:h-64 shrink-0 pb-safe">
<div class={`${isShortLandscape ? 'w-[300px] h-full' : 'h-[50vh] max-h-[450px] md:h-64 w-full'} shrink-0 pb-safe`}>
<ControlPanel
problem={currentProblem}
status={gameState.status}
onAnswer={handleAnswer}
forceVertical={isShortLandscape}
/>
</div>
</div>
@ -265,5 +408,4 @@ const App: React.FC = () => {
</div>
);
};
export default App;

View File

@ -6,9 +6,10 @@ interface ControlPanelProps {
problem: Problem | null;
status: GameStatus;
onAnswer: (answer: number) => void;
forceVertical?: boolean;
}
export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer }) => {
export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer, forceVertical = false }) => {
const [input, setInput] = useState('');
// Clear input when problem changes
@ -40,9 +41,9 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ 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 (
<div className="flex flex-col md:flex-row h-full w-full bg-white rounded-t-2xl shadow-2xl p-2 md:p-4 gap-2 md:gap-4">
{/* Question Display - Smaller on mobile */}
<div className="flex-[0.3] md:flex-1 flex flex-col items-center justify-center bg-amber-50 rounded-xl border-4 border-amber-200 p-2 md:p-4 min-h-[60px]">
<div className={`flex h-full w-full bg-white rounded-t-2xl shadow-2xl p-2 md:p-4 gap-2 md:gap-4 ${forceVertical ? 'flex-col' : 'flex-col md:flex-row'}`}>
{/* Question Display - Smaller on mobile/vertical */}
<div className={`${forceVertical ? 'flex-[0.3]' : 'flex-[0.3] md:flex-1'} flex flex-col items-center justify-center bg-amber-50 rounded-xl border-4 border-amber-200 p-2 md:p-4 min-h-[60px]`}>
<div className="text-xs md:text-sm text-amber-800 font-bold uppercase tracking-wider mb-1 hidden md:block">Build Segment</div>
<div className="flex items-center gap-2 md:gap-4 text-4xl md:text-6xl font-black text-slate-800">
<span>{problem.a}</span>
@ -55,8 +56,8 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onA
</div>
</div>
{/* Numpad - Takes more space on mobile */}
<div className="flex-[0.7] md:flex-1 max-w-md mx-auto w-full h-full">
{/* Numpad - Takes more space on mobile/vertical */}
<div className={`${forceVertical ? 'flex-[0.7]' : 'flex-[0.7] md:flex-1'} max-w-md mx-auto w-full h-full`}>
<form onSubmit={handleSubmit} className="grid grid-cols-3 gap-2 h-full">
{/* Row 1: 1, 2, 3 */}
{[1, 2, 3].map((num) => (

View File

@ -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<GameSceneProps> = ({ gameState }) => {
const { characterX, bridgeEndX } = gameState;
interface FloatingText {
id: number;
x: number;
y: number;
text: string;
color: string;
}
// 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);
export const GameScene: React.FC<GameSceneProps> = ({ gameState, feedback }) => {
const { characterX, bridgeEndX } = gameState;
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(GAME_CONFIG.CANVAS_WIDTH);
const [containerHeight, setContainerHeight] = useState(GAME_CONFIG.CANVAS_HEIGHT);
const [isShaking, setIsShaking] = useState(false);
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
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 (
<div className="w-full h-full relative overflow-hidden bg-sky-200 border-b-8 border-sky-800/20 shadow-inner rounded-xl">
<div ref={containerRef} className={`w-full h-full relative overflow-hidden bg-sky-200 border-b-8 border-sky-800/20 shadow-inner rounded-xl ${isShaking ? 'animate-shake' : ''}`}>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.animate-shake {
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes floatUp {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-60px) scale(1.5); opacity: 0; }
}
.animate-float-up {
animation: floatUp 0.8s ease-out forwards;
}
`}</style>
{/* Background Decor */}
<div className="absolute top-10 left-10 opacity-50 text-white">
<Mountain size={64} />
@ -38,7 +114,7 @@ export const GameScene: React.FC<GameSceneProps> = ({ 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<GameSceneProps> = ({ 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<GameSceneProps> = ({ 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<GameSceneProps> = ({ 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<GameSceneProps> = ({ 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<GameSceneProps> = ({ gameState }) => {
}}
>
GOAL &rarr;
</div> </div>
</div>
{/* Floating Texts */}
{floatingTexts.map(ft => (
<div
key={ft.id}
className={`absolute font-black text-4xl ${ft.color} animate-float-up pointer-events-none`}
style={{ left: ft.x, top: ft.y }}
>
{ft.text}
</div>
))}
</div>
</div>
);
};

View File

@ -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 = {

View File

@ -30,7 +30,6 @@
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-sky-100 text-slate-800 antialiased overflow-hidden">
<div id="root"></div>

View File

@ -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();

View File

@ -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

View File

@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => {
server: {
port: 3000,
host: '0.0.0.0',
allowedHosts: ["delu.local", "delu.home"]
},
plugins: [react()],
define: {