Updates to bridge, timing
This commit is contained in:
parent
9a1ae7c0ee
commit
b5266c704a
@ -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;
|
||||
@ -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) => (
|
||||
|
||||
@ -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 →
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
|
||||
199
src/apps/bridge-builder-math/services/audioService.ts
Normal file
199
src/apps/bridge-builder-math/services/audioService.ts
Normal 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();
|
||||
@ -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
|
||||
|
||||
@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ["delu.local", "delu.home"]
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user