Added builder bridge and dynamic menus
This commit is contained in:
parent
d3f29a0287
commit
9a1ae7c0ee
@ -3,7 +3,8 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler pages deploy dist --project-name cffyin",
|
||||
"deploy": "wrangler pages deploy dist --project-name cffyin --branch main --commit-dirty=true",
|
||||
"preview": "wrangler pages deploy dist --project-name cffyin --branch master --commit-dirty=true",
|
||||
"dev": "wrangler pages dev dist",
|
||||
"start": "wrangler pages dev dist",
|
||||
"cf-typegen": "wrangler types",
|
||||
|
||||
@ -48,10 +48,61 @@ function processHtml(filePath, outPath) {
|
||||
content = NAV_TEMPLATE + content;
|
||||
}
|
||||
|
||||
// Inject Apps Grid if placeholder exists
|
||||
if (content.includes('<!-- APPS_GRID -->')) {
|
||||
const appsGridHtml = generateAppsGridHtml();
|
||||
content = content.replace('<!-- APPS_GRID -->', appsGridHtml);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, content);
|
||||
console.log(`Processed ${filePath} -> ${outPath}`);
|
||||
}
|
||||
|
||||
function generateAppsGridHtml() {
|
||||
if (!fs.existsSync(APPS_DIR)) return '';
|
||||
|
||||
const apps = fs.readdirSync(APPS_DIR).filter(item => {
|
||||
const itemPath = path.join(APPS_DIR, item);
|
||||
return !item.startsWith('.') && fs.statSync(itemPath).isDirectory();
|
||||
});
|
||||
|
||||
return apps.map(app => {
|
||||
const metadataPath = path.join(APPS_DIR, app, 'metadata.json');
|
||||
let metadata = {
|
||||
title: app,
|
||||
description: 'A cool app.',
|
||||
emoji: '📱',
|
||||
color: 'gray',
|
||||
path: `/${app}/`
|
||||
};
|
||||
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
metadata = { ...metadata, ...data };
|
||||
} catch (e) {
|
||||
console.error(`Error reading metadata for ${app}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="group">
|
||||
<a href="${metadata.path}" class="block bg-${metadata.color}-400 border-4 border-${metadata.color}-600 shadow-lg p-6 space-y-4 transition-all duration-300 transform rotate-1 group-hover:rotate-0 group-hover:scale-105">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-3">${metadata.emoji}</div>
|
||||
<h4 class="font-impact text-3xl font-black text-gray-900 mb-2">${metadata.title}</h4>
|
||||
<p class="text-gray-800 font-bold text-lg">${metadata.description}</p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="px-4 py-2 text-sm font-black border-2 bg-green-400 border-green-700 text-green-900">ONLINE</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
|
||||
function buildLanding() {
|
||||
const landingSrc = path.join(SRC_DIR, 'landing', 'index.html');
|
||||
const landingDist = path.join(DIST_DIR, 'index.html');
|
||||
|
||||
BIN
src/.DS_Store
vendored
Executable file
BIN
src/.DS_Store
vendored
Executable file
Binary file not shown.
24
src/apps/.gitignore
vendored
Normal file
24
src/apps/.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?
|
||||
@ -1,5 +1,7 @@
|
||||
{
|
||||
"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": []
|
||||
"title": "Bridge Builder",
|
||||
"description": "Construct bridges using the power of math!",
|
||||
"emoji": "🌉",
|
||||
"color": "purple",
|
||||
"path": "/bridge-builder-math/"
|
||||
}
|
||||
@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
base: './',
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,72 +0,0 @@
|
||||
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" />;
|
||||
};
|
||||
@ -1,105 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,113 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,72 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
7
src/apps/german-trainer/metadata.json
Normal file
7
src/apps/german-trainer/metadata.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Lern mit mir!",
|
||||
"description": "A simple, focused tool for drilling German vocabulary and grammar.",
|
||||
"emoji": "🇩🇪",
|
||||
"color": "cyan",
|
||||
"path": "/german-trainer/"
|
||||
}
|
||||
7
src/apps/hexarow/metadata.json
Normal file
7
src/apps/hexarow/metadata.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "6-in-a-Line",
|
||||
"description": "A classic board game to play against a surprisingly clever AI. Good luck.",
|
||||
"emoji": "🎮",
|
||||
"color": "lime",
|
||||
"path": "/hexarow/"
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
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!"
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -1,92 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -1,5 +1,7 @@
|
||||
{
|
||||
"name": "SnapMark",
|
||||
"description": "A vector-based image annotation tool. Paste images, add editable highlights, arrows, and text, then export as PNG.",
|
||||
"requestFramePermissions": []
|
||||
"title": "SnapMark",
|
||||
"description": "Highlight, annotate, and export images in a snap.",
|
||||
"emoji": "🖍️",
|
||||
"color": "pink",
|
||||
"path": "/snapmark/"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user