Adding bridge builder math

This commit is contained in:
Coffee In 2025-12-13 12:32:50 +00:00
parent 74388a1669
commit d3f29a0287
27 changed files with 4180 additions and 8 deletions

View File

@ -17,6 +17,7 @@ const NAV_TEMPLATE = `
<a href="/german-trainer/" class="hover:underline">German Trainer</a>
<a href="/hexarow/" class="hover:underline">Hex-a-Row</a>
<a href="/snapmark/" class="hover:underline">SnapMark</a>
<a href="/bridge-builder-math/" class="hover:underline">Bridge Builder</a>
</div>
</div>
</nav>
@ -41,8 +42,7 @@ function processHtml(filePath, outPath) {
// Inject new nav
if (content.includes('<body')) {
content = content.replace(/<body([^>]*)>/i, `<body$1>
${NAV_TEMPLATE}`);
content = content.replace(/<body([^>]*)>/i, `<body>\n${NAV_TEMPLATE}`);
} else {
// Fallback if no body tag found (unlikely for valid HTML)
content = NAV_TEMPLATE + content;
@ -53,7 +53,7 @@ ${NAV_TEMPLATE}`);
}
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');
if (fs.existsSync(landingSrc)) {
processHtml(landingSrc, landingDist);
@ -65,14 +65,15 @@ function buildLanding() {
function buildApps() {
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 => {
const appSrc = path.join(APPS_DIR, app);
const appDist = path.join(DIST_DIR, app);
if (!fs.statSync(appSrc).isDirectory()) return;
fs.mkdirSync(appDist, { recursive: true });
// Check if it's a Vite app (has package.json and vite.config.ts)
@ -146,4 +147,4 @@ function main() {
console.log("Build Complete!");
}
main();
main();

24
src/apps/bridge-builder-math/.gitignore vendored Normal file
View 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?

View 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;

View 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`

View 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>
);
};

View 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" />;
};

View 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>
);
};

View 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 &rarr;
</div> </div>
</div>
);
};

View 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>
);
};

View 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',
};

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

View 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>
);

View 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": []
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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!"
};
}
};

View 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,
};
};

View 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
}
}

View 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;
}

View 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, '.'),
}
}
};
});

View 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>
);
};

View 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" />;
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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!"
};
}
};

View 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,
};
};