Adding bridge builder math
This commit is contained in:
parent
74388a1669
commit
d3f29a0287
@ -17,6 +17,7 @@ const NAV_TEMPLATE = `
|
|||||||
<a href="/german-trainer/" class="hover:underline">German Trainer</a>
|
<a href="/german-trainer/" class="hover:underline">German Trainer</a>
|
||||||
<a href="/hexarow/" class="hover:underline">Hex-a-Row</a>
|
<a href="/hexarow/" class="hover:underline">Hex-a-Row</a>
|
||||||
<a href="/snapmark/" class="hover:underline">SnapMark</a>
|
<a href="/snapmark/" class="hover:underline">SnapMark</a>
|
||||||
|
<a href="/bridge-builder-math/" class="hover:underline">Bridge Builder</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -41,8 +42,7 @@ function processHtml(filePath, outPath) {
|
|||||||
|
|
||||||
// Inject new nav
|
// Inject new nav
|
||||||
if (content.includes('<body')) {
|
if (content.includes('<body')) {
|
||||||
content = content.replace(/<body([^>]*)>/i, `<body$1>
|
content = content.replace(/<body([^>]*)>/i, `<body>\n${NAV_TEMPLATE}`);
|
||||||
${NAV_TEMPLATE}`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback if no body tag found (unlikely for valid HTML)
|
// Fallback if no body tag found (unlikely for valid HTML)
|
||||||
content = NAV_TEMPLATE + content;
|
content = NAV_TEMPLATE + content;
|
||||||
@ -53,7 +53,7 @@ ${NAV_TEMPLATE}`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildLanding() {
|
function buildLanding() {
|
||||||
const landingSrc = path.join(__dirname, '..', 'src', 'landing', 'index.html');
|
const landingSrc = path.join(SRC_DIR, 'landing', 'index.html');
|
||||||
const landingDist = path.join(DIST_DIR, 'index.html');
|
const landingDist = path.join(DIST_DIR, 'index.html');
|
||||||
if (fs.existsSync(landingSrc)) {
|
if (fs.existsSync(landingSrc)) {
|
||||||
processHtml(landingSrc, landingDist);
|
processHtml(landingSrc, landingDist);
|
||||||
@ -65,14 +65,15 @@ function buildLanding() {
|
|||||||
function buildApps() {
|
function buildApps() {
|
||||||
if (!fs.existsSync(APPS_DIR)) return;
|
if (!fs.existsSync(APPS_DIR)) return;
|
||||||
|
|
||||||
const apps = fs.readdirSync(APPS_DIR);
|
const apps = fs.readdirSync(APPS_DIR).filter(item => {
|
||||||
|
const itemPath = path.join(APPS_DIR, item);
|
||||||
|
return !item.startsWith('.') && fs.statSync(itemPath).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
apps.forEach(app => {
|
apps.forEach(app => {
|
||||||
const appSrc = path.join(APPS_DIR, app);
|
const appSrc = path.join(APPS_DIR, app);
|
||||||
const appDist = path.join(DIST_DIR, app);
|
const appDist = path.join(DIST_DIR, app);
|
||||||
|
|
||||||
if (!fs.statSync(appSrc).isDirectory()) return;
|
|
||||||
|
|
||||||
fs.mkdirSync(appDist, { recursive: true });
|
fs.mkdirSync(appDist, { recursive: true });
|
||||||
|
|
||||||
// Check if it's a Vite app (has package.json and vite.config.ts)
|
// Check if it's a Vite app (has package.json and vite.config.ts)
|
||||||
|
|||||||
24
src/apps/bridge-builder-math/.gitignore
vendored
Normal file
24
src/apps/bridge-builder-math/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
269
src/apps/bridge-builder-math/App.tsx
Normal file
269
src/apps/bridge-builder-math/App.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
const INITIAL_STATE: GameState = {
|
||||||
|
status: GameStatus.MENU,
|
||||||
|
score: 0,
|
||||||
|
level: 1,
|
||||||
|
speed: GAME_CONFIG.INITIAL_SPEED,
|
||||||
|
characterX: 50,
|
||||||
|
bridgeEndX: GAME_CONFIG.GAP_START,
|
||||||
|
lives: 1,
|
||||||
|
streak: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Celebration State
|
||||||
|
const [showCelebration, setShowCelebration] = useState(false);
|
||||||
|
const [celebrationText, setCelebrationText] = useState("");
|
||||||
|
|
||||||
|
// Refs for loop optimization
|
||||||
|
const requestRef = useRef<number>();
|
||||||
|
const lastTimeRef = useRef<number>();
|
||||||
|
const stateRef = useRef<GameState>(INITIAL_STATE); // Mutable ref for loop
|
||||||
|
|
||||||
|
// Sync state ref
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = gameState;
|
||||||
|
}, [gameState]);
|
||||||
|
|
||||||
|
// Celebration Effect Hook
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameState.streak > 0 && gameState.streak % 10 === 0) {
|
||||||
|
triggerCelebration(`${gameState.streak} STREAK!`);
|
||||||
|
}
|
||||||
|
}, [gameState.streak]);
|
||||||
|
|
||||||
|
// 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}!`);
|
||||||
|
}
|
||||||
|
}, [gameState.level]);
|
||||||
|
|
||||||
|
const triggerCelebration = (text: string) => {
|
||||||
|
setCelebrationText(text);
|
||||||
|
setShowCelebration(true);
|
||||||
|
setTimeout(() => setShowCelebration(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startGame = () => {
|
||||||
|
const startState = {
|
||||||
|
...INITIAL_STATE,
|
||||||
|
status: GameStatus.PLAYING,
|
||||||
|
};
|
||||||
|
setGameState(startState);
|
||||||
|
setCurrentProblem(generateProblem(1, false));
|
||||||
|
lastTimeRef.current = performance.now();
|
||||||
|
requestRef.current = requestAnimationFrame(gameLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gameLoop = (time: number) => {
|
||||||
|
if (stateRef.current.status !== GameStatus.PLAYING) return;
|
||||||
|
|
||||||
|
const deltaTime = time - (lastTimeRef.current || time);
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
|
||||||
|
// Movement Logic
|
||||||
|
// Adjust speed based on delta time (normalize to 60fps ~ 16.6ms)
|
||||||
|
const moveAmount = stateRef.current.speed * (deltaTime / 16.6);
|
||||||
|
|
||||||
|
let newCharacterX = stateRef.current.characterX + moveAmount;
|
||||||
|
|
||||||
|
// Check Fail Condition (Falling)
|
||||||
|
// Character falls if they go past the bridge end
|
||||||
|
if (newCharacterX > stateRef.current.bridgeEndX) {
|
||||||
|
handleGameOver();
|
||||||
|
return; // Stop loop
|
||||||
|
}
|
||||||
|
|
||||||
|
setGameState(prev => ({
|
||||||
|
...prev,
|
||||||
|
characterX: newCharacterX
|
||||||
|
}));
|
||||||
|
|
||||||
|
requestRef.current = requestAnimationFrame(gameLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameOver = () => {
|
||||||
|
if (requestRef.current) cancelAnimationFrame(requestRef.current);
|
||||||
|
|
||||||
|
// Save the problem they failed on (the current one they couldn't solve in time)
|
||||||
|
if (currentProblem) {
|
||||||
|
setLastMissedProblem(currentProblem);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGameState(prev => ({ ...prev, status: GameStatus.GAME_OVER, streak: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnswer = useCallback((answer: number) => {
|
||||||
|
if (!currentProblem) return;
|
||||||
|
|
||||||
|
if (answer === currentProblem.answer) {
|
||||||
|
// Correct!
|
||||||
|
setGameState(prev => {
|
||||||
|
const newScore = prev.score + 10;
|
||||||
|
const newLevel = Math.floor(newScore / 100) + 1;
|
||||||
|
|
||||||
|
// Speed scaling: Base + (Level-1 * Increment)
|
||||||
|
const speedMultiplier = 1 + ((newLevel - 1) * GAME_CONFIG.SPEED_INCREMENT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
bridgeEndX: prev.bridgeEndX + GAME_CONFIG.SEGMENT_WIDTH,
|
||||||
|
score: newScore,
|
||||||
|
level: newLevel,
|
||||||
|
speed: Math.min(GAME_CONFIG.MAX_SPEED, GAME_CONFIG.INITIAL_SPEED * speedMultiplier),
|
||||||
|
streak: prev.streak + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Incorrect
|
||||||
|
setGameState(prev => ({ ...prev, streak: 0 }));
|
||||||
|
// Visual feedback logic could go here (e.g. screen shake), handled by UI via streak reset
|
||||||
|
}
|
||||||
|
}, [currentProblem]);
|
||||||
|
|
||||||
|
// Cleanup loop
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (requestRef.current) cancelAnimationFrame(requestRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{gameState.status === GameStatus.PLAYING && (
|
||||||
|
<div className="flex gap-4 md:gap-8 font-bold text-slate-600">
|
||||||
|
{/* Streak Indicator */}
|
||||||
|
<div className={`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"} />
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-xs uppercase text-slate-400">Level</span>
|
||||||
|
<span className="text-xl">{gameState.level}</span>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
|
||||||
|
{/* Game Viewport
|
||||||
|
On Mobile: flex-grow but with a small min-height to allow controls to take space.
|
||||||
|
On Desktop: Standard behavior.
|
||||||
|
*/}
|
||||||
|
<div className="flex-1 w-full min-h-[200px] relative shrink">
|
||||||
|
<GameScene gameState={gameState} />
|
||||||
|
|
||||||
|
{/* Celebration Overlay */}
|
||||||
|
{showCelebration && (
|
||||||
|
<>
|
||||||
|
<Confetti />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-30 pointer-events-none">
|
||||||
|
<h1 className="text-5xl md:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-red-600 drop-shadow-lg animate-pop stroke-white stroke-2" style={{ WebkitTextStroke: '2px white' }}>
|
||||||
|
{celebrationText}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlays */}
|
||||||
|
{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>
|
||||||
|
<Button onClick={startGame} size="lg" className="animate-bounce">
|
||||||
|
<Play className="mr-2" /> Start Game
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button onClick={startGame} variant="primary">
|
||||||
|
<RotateCcw className="mr-2" /> Try Again
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{lastMissedProblem && (
|
||||||
|
<Button onClick={() => setIsTutorOpen(true)} variant="secondary">
|
||||||
|
<BrainCircuit className="mr-2" /> Why did I fall?
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls
|
||||||
|
On Mobile: Takes ~50% of vertical space or 400px, whichever is less/more suitable.
|
||||||
|
On Desktop: Fixed height 16rem (256px).
|
||||||
|
*/}
|
||||||
|
<div className="h-[50vh] max-h-[450px] md:h-64 shrink-0 pb-safe">
|
||||||
|
<ControlPanel
|
||||||
|
problem={currentProblem}
|
||||||
|
status={gameState.status}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Tutor Modal */}
|
||||||
|
{lastMissedProblem && (
|
||||||
|
<TutorModal
|
||||||
|
isOpen={isTutorOpen}
|
||||||
|
onClose={() => setIsTutorOpen(false)}
|
||||||
|
problem={lastMissedProblem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
20
src/apps/bridge-builder-math/README.md
Normal file
20
src/apps/bridge-builder-math/README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/drive/15AVEVIv79IRq8wF6VE-sUIV8eJgsshe2
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
37
src/apps/bridge-builder-math/components/Button.tsx
Normal file
37
src/apps/bridge-builder-math/components/Button.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseStyles = "font-bold rounded-xl shadow-[0_4px_0_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-green-500 text-white hover:bg-green-400 border-2 border-green-600",
|
||||||
|
secondary: "bg-sky-500 text-white hover:bg-sky-400 border-2 border-sky-600",
|
||||||
|
danger: "bg-red-500 text-white hover:bg-red-400 border-2 border-red-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "px-4 py-2 text-sm",
|
||||||
|
md: "px-6 py-3 text-lg",
|
||||||
|
lg: "px-8 py-4 text-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/apps/bridge-builder-math/components/Confetti.tsx
Normal file
72
src/apps/bridge-builder-math/components/Confetti.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { COLORS } from '../constants';
|
||||||
|
|
||||||
|
export const Confetti: React.FC = () => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Set dimensions
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
const particles: any[] = [];
|
||||||
|
// Bright celebratory colors
|
||||||
|
const colors = [COLORS.grass, COLORS.bridge, COLORS.character, '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899'];
|
||||||
|
|
||||||
|
// Create particles
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height * 0.3,
|
||||||
|
vx: (Math.random() - 0.5) * 25,
|
||||||
|
vy: (Math.random() - 0.5) * 25 - 5,
|
||||||
|
size: Math.random() * 8 + 4,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
rotation: Math.random() * 360,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 15,
|
||||||
|
gravity: 0.6,
|
||||||
|
drag: 0.95
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationId: number;
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
let activeParticles = 0;
|
||||||
|
particles.forEach((p) => {
|
||||||
|
if (p.y < canvas.height + 20) {
|
||||||
|
activeParticles++;
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.vy += p.gravity;
|
||||||
|
p.vx *= p.drag;
|
||||||
|
p.y *= 1.0;
|
||||||
|
p.rotation += p.rotationSpeed;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate((p.rotation * Math.PI) / 180);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeParticles > 0) {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(animationId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="fixed inset-0 pointer-events-none z-50" />;
|
||||||
|
};
|
||||||
105
src/apps/bridge-builder-math/components/ControlPanel.tsx
Normal file
105
src/apps/bridge-builder-math/components/ControlPanel.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Problem, GameStatus } from '../types';
|
||||||
|
import { Delete, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ControlPanelProps {
|
||||||
|
problem: Problem | null;
|
||||||
|
status: GameStatus;
|
||||||
|
onAnswer: (answer: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer }) => {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
// Clear input when problem changes
|
||||||
|
useEffect(() => {
|
||||||
|
setInput('');
|
||||||
|
}, [problem?.id]);
|
||||||
|
|
||||||
|
const handleNumClick = (num: number) => {
|
||||||
|
if (input.length < 3) { // Max 3 digits (answer < 200 usually)
|
||||||
|
setInput(prev => prev + num.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setInput(prev => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (input === '') return;
|
||||||
|
onAnswer(parseInt(input, 10));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status !== GameStatus.PLAYING || !problem) {
|
||||||
|
return <div className="h-full bg-slate-100 rounded-t-xl animate-pulse"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button style helper
|
||||||
|
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="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>
|
||||||
|
<span className="text-amber-600">{problem.operator}</span>
|
||||||
|
<span>{problem.b}</span>
|
||||||
|
<span>=</span>
|
||||||
|
<span className="text-blue-600 border-b-4 border-dashed border-blue-300 min-w-[1.5em] text-center">
|
||||||
|
{input || '?'}
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-3 gap-2 h-full">
|
||||||
|
{/* Row 1: 1, 2, 3 */}
|
||||||
|
{[1, 2, 3].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 2: 4, 5, 6 */}
|
||||||
|
{[4, 5, 6].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 3: 7, 8, 9 */}
|
||||||
|
{[7, 8, 9].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 4: Del, 0, Enter */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-100 hover:bg-red-200 text-red-600 font-bold rounded-lg shadow-sm border-b-4 border-red-200 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => handleNumClick(0)} className={btnClass}>
|
||||||
|
0
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 hover:bg-green-400 text-white font-bold rounded-lg shadow-sm border-b-4 border-green-600 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"
|
||||||
|
>
|
||||||
|
<ArrowRight size={32} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
112
src/apps/bridge-builder-math/components/GameScene.tsx
Normal file
112
src/apps/bridge-builder-math/components/GameScene.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GameState } from '../types';
|
||||||
|
import { GAME_CONFIG, COLORS } from '../constants';
|
||||||
|
import { HardHat, Mountain } from 'lucide-react';
|
||||||
|
|
||||||
|
interface GameSceneProps {
|
||||||
|
gameState: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameScene: React.FC<GameSceneProps> = ({ gameState }) => {
|
||||||
|
const { characterX, bridgeEndX } = gameState;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative overflow-hidden bg-sky-200 border-b-8 border-sky-800/20 shadow-inner rounded-xl">
|
||||||
|
{/* Background Decor */}
|
||||||
|
<div className="absolute top-10 left-10 opacity-50 text-white">
|
||||||
|
<Mountain size={64} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-20 right-20 opacity-50 text-white">
|
||||||
|
<Mountain size={96} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game World Container with transform for scrolling */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 will-change-transform"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(-${viewOffset}px)`,
|
||||||
|
transition: 'transform 0.1s linear'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ground (Starting Platform) */}
|
||||||
|
<div
|
||||||
|
className="absolute border-r-4 border-amber-900"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
width: GAME_CONFIG.GAP_START,
|
||||||
|
height: '200vh', // Extend deep to cover tall screens (foldables)
|
||||||
|
backgroundColor: COLORS.grass,
|
||||||
|
backgroundImage: `linear-gradient(135deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(225deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(45deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(315deg, ${COLORS.grass} 25%, ${COLORS.dirt} 25%)`,
|
||||||
|
backgroundPosition: '10px 0, 10px 0, 0 0, 0 0',
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
backgroundRepeat: 'repeat'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The Abyss (Visual only, behind bridge) */}
|
||||||
|
<div
|
||||||
|
className="absolute w-[9999px]"
|
||||||
|
style={{
|
||||||
|
left: GAME_CONFIG.GAP_START,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
height: '200vh', // Extend deep
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bridge Segments */}
|
||||||
|
<div
|
||||||
|
className="absolute h-4 shadow-lg border-y-2 border-amber-900"
|
||||||
|
style={{
|
||||||
|
left: GAME_CONFIG.GAP_START,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
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)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Character */}
|
||||||
|
<div
|
||||||
|
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,
|
||||||
|
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'})`,
|
||||||
|
transition: 'transform 0.5s ease-in'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardHat size={32} color={COLORS.character} fill="orange" />
|
||||||
|
<div className="w-4 h-8 bg-blue-600 rounded-b-lg -mt-1"></div>
|
||||||
|
{/* Legs animation simulation */}
|
||||||
|
<div className="flex w-full justify-between px-1">
|
||||||
|
<div className="w-1 h-3 bg-black animate-pulse"></div>
|
||||||
|
<div className="w-1 h-3 bg-black animate-pulse delay-75"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Platform Marker (Visual Goal) */}
|
||||||
|
<div
|
||||||
|
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,
|
||||||
|
width: 100,
|
||||||
|
height: '200vh', // Extend deep
|
||||||
|
backgroundColor: COLORS.grass,
|
||||||
|
opacity: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GOAL →
|
||||||
|
</div> </div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/apps/bridge-builder-math/components/TutorModal.tsx
Normal file
72
src/apps/bridge-builder-math/components/TutorModal.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Problem, TutorResponse } from '../types';
|
||||||
|
import { getTutorExplanation } from '../services/geminiService';
|
||||||
|
import { Bot, Loader2, X } from 'lucide-react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface TutorModalProps {
|
||||||
|
problem: Problem;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TutorModal: React.FC<TutorModalProps> = ({ problem, onClose, isOpen }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<TutorResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && problem) {
|
||||||
|
setLoading(true);
|
||||||
|
getTutorExplanation(problem)
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, problem]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="bg-indigo-600 p-4 flex items-center justify-between text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot />
|
||||||
|
<span className="font-bold text-lg">Math Coach</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="hover:bg-indigo-500 p-1 rounded">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-slate-500 text-sm uppercase tracking-wide font-bold mb-1">The problem was</p>
|
||||||
|
<div className="text-4xl font-black text-slate-800">
|
||||||
|
{problem.a} {problem.operator} {problem.b} = <span className="text-red-500">{problem.answer}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-indigo-500">
|
||||||
|
<Loader2 className="animate-spin mb-2" size={32} />
|
||||||
|
<p>Thinking...</p>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
|
||||||
|
<p className="text-indigo-900 font-medium leading-relaxed">{data.explanation}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-slate-500 italic">"{data.encouragement}"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-red-500 text-center">Could not load explanation.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button onClick={onClose} className="w-full">Got it!</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
src/apps/bridge-builder-math/constants.ts
Normal file
20
src/apps/bridge-builder-math/constants.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export const GAME_CONFIG = {
|
||||||
|
CANVAS_WIDTH: 800, // internal logical width
|
||||||
|
CANVAS_HEIGHT: 400, // internal logical height
|
||||||
|
BRIDGE_Y: 250,
|
||||||
|
CHARACTER_SIZE: 40,
|
||||||
|
SEGMENT_WIDTH: 100, // Width added per correct answer
|
||||||
|
INITIAL_SPEED: 0.08, // Tuned ~20s per segment to allow 70 Qs in 20 mins
|
||||||
|
SPEED_INCREMENT: 0.05, // 5% speed increase per level
|
||||||
|
MAX_SPEED: 1.0,
|
||||||
|
GAP_START: 200, // Increased start buffer
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
sky: '#bae6fd',
|
||||||
|
grass: '#4ade80',
|
||||||
|
dirt: '#854d0e',
|
||||||
|
bridge: '#d97706',
|
||||||
|
bridgeDark: '#92400e',
|
||||||
|
character: '#facc15',
|
||||||
|
};
|
||||||
39
src/apps/bridge-builder-math/index.html
Normal file
39
src/apps/bridge-builder-math/index.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Bridge Builder Math</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
@keyframes pop-in {
|
||||||
|
0% { transform: scale(0) rotate(-10deg); opacity: 0; }
|
||||||
|
60% { transform: scale(1.2) rotate(5deg); opacity: 1; }
|
||||||
|
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-pop {
|
||||||
|
animation: pop-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@google/genai": "https://esm.sh/@google/genai@^1.33.0",
|
||||||
|
"react/": "https://esm.sh/react@^19.2.3/",
|
||||||
|
"react": "https://esm.sh/react@^19.2.3",
|
||||||
|
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||||
|
"lucide-react": "https://esm.sh/lucide-react@^0.561.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-sky-100 text-slate-800 antialiased overflow-hidden">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
src/apps/bridge-builder-math/index.tsx
Normal file
15
src/apps/bridge-builder-math/index.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
5
src/apps/bridge-builder-math/metadata.json
Normal file
5
src/apps/bridge-builder-math/metadata.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Bridge Builder Math",
|
||||||
|
"description": "An educational math game where players solve addition and subtraction problems to build a bridge for a character to cross a canyon.",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
2581
src/apps/bridge-builder-math/package-lock.json
generated
Normal file
2581
src/apps/bridge-builder-math/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
src/apps/bridge-builder-math/package.json
Normal file
23
src/apps/bridge-builder-math/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "bridge-builder-math",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/genai": "^1.33.0",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"lucide-react": "^0.561.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/apps/bridge-builder-math/services/geminiService.ts
Normal file
55
src/apps/bridge-builder-math/services/geminiService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { GoogleGenAI, Type } from "@google/genai";
|
||||||
|
import { Problem, TutorResponse } from "../types";
|
||||||
|
|
||||||
|
// Helper to check for API key availability
|
||||||
|
export const hasApiKey = (): boolean => {
|
||||||
|
return !!process.env.API_KEY;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTutorExplanation = async (problem: Problem): Promise<TutorResponse> => {
|
||||||
|
if (!process.env.API_KEY) {
|
||||||
|
return {
|
||||||
|
explanation: "Practice makes perfect! Try visualizing the numbers as blocks.",
|
||||||
|
encouragement: "You can do it next time!"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
Explain how to solve the math problem ${problem.a} ${problem.operator} ${problem.b} to a 7-year-old child.
|
||||||
|
The answer is ${problem.answer}.
|
||||||
|
If it involves regrouping (carrying/borrowing), explicitly explain that step simply.
|
||||||
|
Keep the explanation under 3 sentences.
|
||||||
|
Also provide a short encouraging phrase.
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
responseSchema: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
explanation: { type: Type.STRING },
|
||||||
|
encouragement: { type: Type.STRING },
|
||||||
|
},
|
||||||
|
required: ["explanation", "encouragement"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.text;
|
||||||
|
if (!text) throw new Error("No response from AI");
|
||||||
|
|
||||||
|
return JSON.parse(text) as TutorResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gemini API Error:", error);
|
||||||
|
return {
|
||||||
|
explanation: "Remember to check if you need to carry over a ten or borrow one!",
|
||||||
|
encouragement: "Keep building!"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
92
src/apps/bridge-builder-math/services/mathEngine.ts
Normal file
92
src/apps/bridge-builder-math/services/mathEngine.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { Problem, Operator } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random integer between min and max (inclusive)
|
||||||
|
*/
|
||||||
|
const randomInt = (min: number, max: number): number => {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an addition problem requires regrouping (carrying).
|
||||||
|
* e.g., 18 + 5 (8+5 > 9)
|
||||||
|
*/
|
||||||
|
const isRegroupingAdd = (a: number, b: number): boolean => {
|
||||||
|
return (a % 10) + (b % 10) >= 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a subtraction problem requires regrouping (borrowing).
|
||||||
|
* e.g., 22 - 5 (2 < 5)
|
||||||
|
*/
|
||||||
|
const isRegroupingSub = (a: number, b: number): boolean => {
|
||||||
|
return (a % 10) < (b % 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProblem = (level: number, forceRegrouping: boolean = false): Problem => {
|
||||||
|
// Level 1: Single digits (0-9). Level 2+: Standard constraints (<100).
|
||||||
|
const operator: Operator = Math.random() > 0.5 ? '+' : '-';
|
||||||
|
|
||||||
|
let a = 0;
|
||||||
|
let b = 0;
|
||||||
|
let answer = 0;
|
||||||
|
let requiresRegrouping = false;
|
||||||
|
let valid = false;
|
||||||
|
|
||||||
|
// Safety break counter
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (!valid && attempts < 100) {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (level === 1) {
|
||||||
|
// Single Digit Mode
|
||||||
|
if (operator === '+') {
|
||||||
|
a = randomInt(0, 9);
|
||||||
|
b = randomInt(0, 9);
|
||||||
|
answer = a + b;
|
||||||
|
requiresRegrouping = isRegroupingAdd(a, b);
|
||||||
|
} else {
|
||||||
|
a = randomInt(0, 9);
|
||||||
|
b = randomInt(0, a); // Result >= 0
|
||||||
|
answer = a - b;
|
||||||
|
requiresRegrouping = isRegroupingSub(a, b);
|
||||||
|
}
|
||||||
|
valid = true;
|
||||||
|
} else {
|
||||||
|
// Standard Mode (Level 2+)
|
||||||
|
// Constraint: Operands >= 0
|
||||||
|
// Constraint: Result < 100 (and >= 0 for sub)
|
||||||
|
|
||||||
|
if (operator === '+') {
|
||||||
|
// a + b = c, c < 100
|
||||||
|
a = randomInt(0, 89); // leave room for b
|
||||||
|
b = randomInt(1, 99 - a);
|
||||||
|
answer = a + b;
|
||||||
|
requiresRegrouping = isRegroupingAdd(a, b);
|
||||||
|
} else {
|
||||||
|
// a - b = c, a < 100, c >= 0
|
||||||
|
a = randomInt(1, 99);
|
||||||
|
b = randomInt(1, a);
|
||||||
|
answer = a - b;
|
||||||
|
requiresRegrouping = isRegroupingSub(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher levels or 'forceRegrouping' prefer regrouping problems
|
||||||
|
if (forceRegrouping) {
|
||||||
|
if (requiresRegrouping) valid = true;
|
||||||
|
} else {
|
||||||
|
valid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
operator,
|
||||||
|
answer,
|
||||||
|
requiresRegrouping,
|
||||||
|
};
|
||||||
|
};
|
||||||
29
src/apps/bridge-builder-math/tsconfig.json
Normal file
29
src/apps/bridge-builder-math/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/apps/bridge-builder-math/types.ts
Normal file
32
src/apps/bridge-builder-math/types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type Operator = '+' | '-';
|
||||||
|
|
||||||
|
export interface Problem {
|
||||||
|
id: string;
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
operator: Operator;
|
||||||
|
answer: number;
|
||||||
|
requiresRegrouping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GameStatus {
|
||||||
|
MENU = 'MENU',
|
||||||
|
PLAYING = 'PLAYING',
|
||||||
|
GAME_OVER = 'GAME_OVER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
status: GameStatus;
|
||||||
|
score: number;
|
||||||
|
level: number;
|
||||||
|
speed: number; // pixels per frame
|
||||||
|
characterX: number; // current position
|
||||||
|
bridgeEndX: number; // how far the bridge is built
|
||||||
|
lives: number;
|
||||||
|
streak: number; // consecutive correct answers
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorResponse {
|
||||||
|
explanation: string;
|
||||||
|
encouragement: string;
|
||||||
|
}
|
||||||
23
src/apps/bridge-builder-math/vite.config.ts
Normal file
23
src/apps/bridge-builder-math/vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
37
src/apps/components/Button.tsx
Normal file
37
src/apps/components/Button.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseStyles = "font-bold rounded-xl shadow-[0_4px_0_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-green-500 text-white hover:bg-green-400 border-2 border-green-600",
|
||||||
|
secondary: "bg-sky-500 text-white hover:bg-sky-400 border-2 border-sky-600",
|
||||||
|
danger: "bg-red-500 text-white hover:bg-red-400 border-2 border-red-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: "px-4 py-2 text-sm",
|
||||||
|
md: "px-6 py-3 text-lg",
|
||||||
|
lg: "px-8 py-4 text-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/apps/components/Confetti.tsx
Normal file
72
src/apps/components/Confetti.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { COLORS } from '../constants';
|
||||||
|
|
||||||
|
export const Confetti: React.FC = () => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Set dimensions
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
const particles: any[] = [];
|
||||||
|
// Bright celebratory colors
|
||||||
|
const colors = [COLORS.grass, COLORS.bridge, COLORS.character, '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899'];
|
||||||
|
|
||||||
|
// Create particles
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height * 0.3,
|
||||||
|
vx: (Math.random() - 0.5) * 25,
|
||||||
|
vy: (Math.random() - 0.5) * 25 - 5,
|
||||||
|
size: Math.random() * 8 + 4,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
rotation: Math.random() * 360,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 15,
|
||||||
|
gravity: 0.6,
|
||||||
|
drag: 0.95
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let animationId: number;
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
let activeParticles = 0;
|
||||||
|
particles.forEach((p) => {
|
||||||
|
if (p.y < canvas.height + 20) {
|
||||||
|
activeParticles++;
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
p.vy += p.gravity;
|
||||||
|
p.vx *= p.drag;
|
||||||
|
p.y *= 1.0;
|
||||||
|
p.rotation += p.rotationSpeed;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate((p.rotation * Math.PI) / 180);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeParticles > 0) {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(animationId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="fixed inset-0 pointer-events-none z-50" />;
|
||||||
|
};
|
||||||
105
src/apps/components/ControlPanel.tsx
Normal file
105
src/apps/components/ControlPanel.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Problem, GameStatus } from '../types';
|
||||||
|
import { Delete, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ControlPanelProps {
|
||||||
|
problem: Problem | null;
|
||||||
|
status: GameStatus;
|
||||||
|
onAnswer: (answer: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlPanel: React.FC<ControlPanelProps> = ({ problem, status, onAnswer }) => {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
// Clear input when problem changes
|
||||||
|
useEffect(() => {
|
||||||
|
setInput('');
|
||||||
|
}, [problem?.id]);
|
||||||
|
|
||||||
|
const handleNumClick = (num: number) => {
|
||||||
|
if (input.length < 3) { // Max 3 digits (answer < 200 usually)
|
||||||
|
setInput(prev => prev + num.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setInput(prev => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (input === '') return;
|
||||||
|
onAnswer(parseInt(input, 10));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status !== GameStatus.PLAYING || !problem) {
|
||||||
|
return <div className="h-full bg-slate-100 rounded-t-xl animate-pulse"></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button style helper
|
||||||
|
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="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>
|
||||||
|
<span className="text-amber-600">{problem.operator}</span>
|
||||||
|
<span>{problem.b}</span>
|
||||||
|
<span>=</span>
|
||||||
|
<span className="text-blue-600 border-b-4 border-dashed border-blue-300 min-w-[1.5em] text-center">
|
||||||
|
{input || '?'}
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<form onSubmit={handleSubmit} className="grid grid-cols-3 gap-2 h-full">
|
||||||
|
{/* Row 1: 1, 2, 3 */}
|
||||||
|
{[1, 2, 3].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 2: 4, 5, 6 */}
|
||||||
|
{[4, 5, 6].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 3: 7, 8, 9 */}
|
||||||
|
{[7, 8, 9].map((num) => (
|
||||||
|
<button key={num} type="button" onClick={() => handleNumClick(num)} className={btnClass}>
|
||||||
|
{num}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 4: Del, 0, Enter */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-100 hover:bg-red-200 text-red-600 font-bold rounded-lg shadow-sm border-b-4 border-red-200 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => handleNumClick(0)} className={btnClass}>
|
||||||
|
0
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 hover:bg-green-400 text-white font-bold rounded-lg shadow-sm border-b-4 border-green-600 active:border-b-0 active:translate-y-1 transition-all h-full flex items-center justify-center touch-manipulation select-none"
|
||||||
|
>
|
||||||
|
<ArrowRight size={32} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
113
src/apps/components/GameScene.tsx
Normal file
113
src/apps/components/GameScene.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GameState } from '../types';
|
||||||
|
import { GAME_CONFIG, COLORS } from '../constants';
|
||||||
|
import { HardHat, Mountain } from 'lucide-react';
|
||||||
|
|
||||||
|
interface GameSceneProps {
|
||||||
|
gameState: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameScene: React.FC<GameSceneProps> = ({ gameState }) => {
|
||||||
|
const { characterX, bridgeEndX } = gameState;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative overflow-hidden bg-sky-200 border-b-8 border-sky-800/20 shadow-inner rounded-xl">
|
||||||
|
{/* Background Decor */}
|
||||||
|
<div className="absolute top-10 left-10 opacity-50 text-white">
|
||||||
|
<Mountain size={64} />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-20 right-20 opacity-50 text-white">
|
||||||
|
<Mountain size={96} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game World Container with transform for scrolling */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 will-change-transform"
|
||||||
|
style={{
|
||||||
|
transform: `translateX(-${viewOffset}px)`,
|
||||||
|
transition: 'transform 0.1s linear'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Ground (Starting Platform) */}
|
||||||
|
<div
|
||||||
|
className="absolute border-r-4 border-amber-900"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
width: GAME_CONFIG.GAP_START,
|
||||||
|
height: '200vh', // Extend deep to cover tall screens (foldables)
|
||||||
|
backgroundColor: COLORS.grass,
|
||||||
|
backgroundImage: `linear-gradient(135deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(225deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(45deg, ${COLORS.grass} 25%, transparent 25%), linear-gradient(315deg, ${COLORS.grass} 25%, ${COLORS.dirt} 25%)`,
|
||||||
|
backgroundPosition: '10px 0, 10px 0, 0 0, 0 0',
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
backgroundRepeat: 'repeat'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The Abyss (Visual only, behind bridge) */}
|
||||||
|
<div
|
||||||
|
className="absolute w-[9999px]"
|
||||||
|
style={{
|
||||||
|
left: GAME_CONFIG.GAP_START,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
height: '200vh', // Extend deep
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bridge Segments */}
|
||||||
|
<div
|
||||||
|
className="absolute h-4 shadow-lg border-y-2 border-amber-900"
|
||||||
|
style={{
|
||||||
|
left: GAME_CONFIG.GAP_START,
|
||||||
|
top: GAME_CONFIG.BRIDGE_Y,
|
||||||
|
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)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Character */}
|
||||||
|
<div
|
||||||
|
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,
|
||||||
|
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'})`,
|
||||||
|
transition: 'transform 0.5s ease-in'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardHat size={32} color={COLORS.character} fill="orange" />
|
||||||
|
<div className="w-4 h-8 bg-blue-600 rounded-b-lg -mt-1"></div>
|
||||||
|
{/* Legs animation simulation */}
|
||||||
|
<div className="flex w-full justify-between px-1">
|
||||||
|
<div className="w-1 h-3 bg-black animate-pulse"></div>
|
||||||
|
<div className="w-1 h-3 bg-black animate-pulse delay-75"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Platform Marker (Visual Goal) */}
|
||||||
|
<div
|
||||||
|
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,
|
||||||
|
width: 100,
|
||||||
|
height: '200vh', // Extend deep
|
||||||
|
backgroundColor: COLORS.grass,
|
||||||
|
opacity: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
GOAL ->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/apps/components/TutorModal.tsx
Normal file
72
src/apps/components/TutorModal.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Problem, TutorResponse } from '../types';
|
||||||
|
import { getTutorExplanation } from '../services/geminiService';
|
||||||
|
import { Bot, Loader2, X } from 'lucide-react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface TutorModalProps {
|
||||||
|
problem: Problem;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TutorModal: React.FC<TutorModalProps> = ({ problem, onClose, isOpen }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<TutorResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && problem) {
|
||||||
|
setLoading(true);
|
||||||
|
getTutorExplanation(problem)
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, problem]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="bg-indigo-600 p-4 flex items-center justify-between text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot />
|
||||||
|
<span className="font-bold text-lg">Math Coach</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="hover:bg-indigo-500 p-1 rounded">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-slate-500 text-sm uppercase tracking-wide font-bold mb-1">The problem was</p>
|
||||||
|
<div className="text-4xl font-black text-slate-800">
|
||||||
|
{problem.a} {problem.operator} {problem.b} = <span className="text-red-500">{problem.answer}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-indigo-500">
|
||||||
|
<Loader2 className="animate-spin mb-2" size={32} />
|
||||||
|
<p>Thinking...</p>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
|
||||||
|
<p className="text-indigo-900 font-medium leading-relaxed">{data.explanation}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-slate-500 italic">"{data.encouragement}"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-red-500 text-center">Could not load explanation.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Button onClick={onClose} className="w-full">Got it!</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
src/apps/services/geminiService.ts
Normal file
55
src/apps/services/geminiService.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { GoogleGenAI, Type } from "@google/genai";
|
||||||
|
import { Problem, TutorResponse } from "../types";
|
||||||
|
|
||||||
|
// Helper to check for API key availability
|
||||||
|
export const hasApiKey = (): boolean => {
|
||||||
|
return !!process.env.API_KEY;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTutorExplanation = async (problem: Problem): Promise<TutorResponse> => {
|
||||||
|
if (!process.env.API_KEY) {
|
||||||
|
return {
|
||||||
|
explanation: "Practice makes perfect! Try visualizing the numbers as blocks.",
|
||||||
|
encouragement: "You can do it next time!"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||||
|
|
||||||
|
const prompt = `
|
||||||
|
Explain how to solve the math problem ${problem.a} ${problem.operator} ${problem.b} to a 7-year-old child.
|
||||||
|
The answer is ${problem.answer}.
|
||||||
|
If it involves regrouping (carrying/borrowing), explicitly explain that step simply.
|
||||||
|
Keep the explanation under 3 sentences.
|
||||||
|
Also provide a short encouraging phrase.
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
contents: prompt,
|
||||||
|
config: {
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
responseSchema: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
explanation: { type: Type.STRING },
|
||||||
|
encouragement: { type: Type.STRING },
|
||||||
|
},
|
||||||
|
required: ["explanation", "encouragement"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.text;
|
||||||
|
if (!text) throw new Error("No response from AI");
|
||||||
|
|
||||||
|
return JSON.parse(text) as TutorResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gemini API Error:", error);
|
||||||
|
return {
|
||||||
|
explanation: "Remember to check if you need to carry over a ten or borrow one!",
|
||||||
|
encouragement: "Keep building!"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
92
src/apps/services/mathEngine.ts
Normal file
92
src/apps/services/mathEngine.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { Problem, Operator } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random integer between min and max (inclusive)
|
||||||
|
*/
|
||||||
|
const randomInt = (min: number, max: number): number => {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an addition problem requires regrouping (carrying).
|
||||||
|
* e.g., 18 + 5 (8+5 > 9)
|
||||||
|
*/
|
||||||
|
const isRegroupingAdd = (a: number, b: number): boolean => {
|
||||||
|
return (a % 10) + (b % 10) >= 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a subtraction problem requires regrouping (borrowing).
|
||||||
|
* e.g., 22 - 5 (2 < 5)
|
||||||
|
*/
|
||||||
|
const isRegroupingSub = (a: number, b: number): boolean => {
|
||||||
|
return (a % 10) < (b % 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProblem = (level: number, forceRegrouping: boolean = false): Problem => {
|
||||||
|
// Level 1: Single digits (0-9). Level 2+: Standard constraints (<100).
|
||||||
|
const operator: Operator = Math.random() > 0.5 ? '+' : '-';
|
||||||
|
|
||||||
|
let a = 0;
|
||||||
|
let b = 0;
|
||||||
|
let answer = 0;
|
||||||
|
let requiresRegrouping = false;
|
||||||
|
let valid = false;
|
||||||
|
|
||||||
|
// Safety break counter
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (!valid && attempts < 100) {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (level === 1) {
|
||||||
|
// Single Digit Mode
|
||||||
|
if (operator === '+') {
|
||||||
|
a = randomInt(0, 9);
|
||||||
|
b = randomInt(0, 9);
|
||||||
|
answer = a + b;
|
||||||
|
requiresRegrouping = isRegroupingAdd(a, b);
|
||||||
|
} else {
|
||||||
|
a = randomInt(0, 9);
|
||||||
|
b = randomInt(0, a); // Result >= 0
|
||||||
|
answer = a - b;
|
||||||
|
requiresRegrouping = isRegroupingSub(a, b);
|
||||||
|
}
|
||||||
|
valid = true;
|
||||||
|
} else {
|
||||||
|
// Standard Mode (Level 2+)
|
||||||
|
// Constraint: Operands >= 0
|
||||||
|
// Constraint: Result < 100 (and >= 0 for sub)
|
||||||
|
|
||||||
|
if (operator === '+') {
|
||||||
|
// a + b = c, c < 100
|
||||||
|
a = randomInt(0, 89); // leave room for b
|
||||||
|
b = randomInt(1, 99 - a);
|
||||||
|
answer = a + b;
|
||||||
|
requiresRegrouping = isRegroupingAdd(a, b);
|
||||||
|
} else {
|
||||||
|
// a - b = c, a < 100, c >= 0
|
||||||
|
a = randomInt(1, 99);
|
||||||
|
b = randomInt(1, a);
|
||||||
|
answer = a - b;
|
||||||
|
requiresRegrouping = isRegroupingSub(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Higher levels or 'forceRegrouping' prefer regrouping problems
|
||||||
|
if (forceRegrouping) {
|
||||||
|
if (requiresRegrouping) valid = true;
|
||||||
|
} else {
|
||||||
|
valid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
operator,
|
||||||
|
answer,
|
||||||
|
requiresRegrouping,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user