From c6821e0e70f1143aa65a83d00db8e1e6d093e37f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:47:30 +0000 Subject: [PATCH 01/15] Add 5 original "Gizmo" goggle-buddy mascots to the inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capsule-style goggled characters (inspired by the goggled-buddy look, but NOT Minions): an upright pill body with a bolt antenna and a riveted goggle strap holding metal lenses — distinct silhouette and non-yellow shells keep them clearly original. - gizmoTeal, gizmoCoral, gizmoViolet, gizmoLime (two-eyed) and gizmoCyan (one-eyed cyclops), each expression-aware (happy/cheer/surprised/dizzy/ko). - Registered in MascotKind + MASCOT_KINDS + PARTS, and added to the Math Runner and Lucky Crawl hero pickers so they're pullable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YJgCstFD5JgXpXb7CbsG5V --- src/routes/arcade/GreedyCrawler.tsx | 2 +- src/routes/arcade/Mascots.tsx | 77 ++++++++++++++++++++++++++++- src/routes/arcade/MathRunner.tsx | 2 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/routes/arcade/GreedyCrawler.tsx b/src/routes/arcade/GreedyCrawler.tsx index a73d2f5..3ab0166 100644 --- a/src/routes/arcade/GreedyCrawler.tsx +++ b/src/routes/arcade/GreedyCrawler.tsx @@ -15,7 +15,7 @@ import { sfx, haptic, HAPTIC } from '../../utils/arcadeAV'; // gamble stops being worth it. Trip the alarm and you lose the un-banked loot. const TARGET = 50; // gold you must BANK to win -const HEROES: MascotKind[] = ['fox', 'panda', 'bunny', 'penguin', 'redpanda', 'cat']; +const HEROES: MascotKind[] = ['fox', 'panda', 'bunny', 'penguin', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan']; // reward for entering the n-th room of a run (1-based) — deeper = richer const rewardForRoom = (n: number) => 2 + 2 * n; diff --git a/src/routes/arcade/Mascots.tsx b/src/routes/arcade/Mascots.tsx index d752fd3..4309874 100644 --- a/src/routes/arcade/Mascots.tsx +++ b/src/routes/arcade/Mascots.tsx @@ -33,7 +33,12 @@ export type MascotKind = | 'capsuleP' | 'capsuleM' | 'cow' - | 'bull'; + | 'bull' + | 'gizmoTeal' + | 'gizmoCoral' + | 'gizmoViolet' + | 'gizmoLime' + | 'gizmoCyan'; export type MascotExpr = 'happy' | 'surprised' | 'dizzy' | 'cheer' | 'ko'; @@ -42,6 +47,7 @@ export const MASCOT_KINDS: MascotKind[] = [ 'unicorn', 'penguin', 'monkey', 'crewmate', 'crewmate2', 'crewmate3', 'panda', 'ninja', 'clerk', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', + 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan', ]; const OUTLINE = { stroke: '#1f2937', strokeWidth: 4, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const }; @@ -680,6 +686,70 @@ function makeCapsule(shell: string, shellDark: string, bow = false) { ); } +// "Gizmo" goggle-buddies — ORIGINAL capsule creatures (NOT Minions): an upright +// pill body with a bolt antenna and a riveted goggle strap holding one or two +// metal lenses. Bright non-yellow shells and a distinct tall silhouette keep them +// clearly original. +function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2; antenna?: string } = {}) { + const eyes = opts.eyes ?? 2; + const ant = opts.antenna ?? shellDark; + const gid = `m-gg-${shell.slice(1)}`; + const my = 66; // mouth y + const lens = (cx: number, r: number, e: MascotExpr) => ( + + + + {e === 'dizzy' ? ( + + ) : e === 'ko' ? ( + + ) : ( + + + + + )} + + ); + return (e: MascotExpr) => ( + + + + + + + + {/* antenna + bolt */} + + + {/* feet */} + + + {/* stub arms */} + + + {/* pill body */} + + {/* goggle strap with rivets */} + + + + {/* lenses */} + {eyes === 1 ? lens(50, 9, e) : [lens(41, 7, e), lens(59, 7, e)]} + {/* mouth */} + {e === 'cheer' ? ( + + ) : e === 'surprised' ? ( + + ) : e === 'ko' ? ( + + ) : ( + + )} + + ); +} + // Chibi cow — tan head with pointy ears, little horns, a beige muzzle, blush and // a colourful cheek badge. (Inspired by a cow-mascot lineage; original art.) function Cow(e: MascotExpr) { @@ -766,6 +836,11 @@ const PARTS: Record JSX.Element> = { capsuleM: makeCapsule('#10b981', '#047857'), cow: Cow, bull: Bull, + gizmoTeal: makeGoggle('#2dd4bf', '#0f766e', { antenna: '#f59e0b' }), + gizmoCoral: makeGoggle('#fb7185', '#be123c', { antenna: '#fcd34d' }), + gizmoViolet: makeGoggle('#a78bfa', '#6d28d9', { antenna: '#34d399' }), + gizmoLime: makeGoggle('#a3e635', '#4d7c0f', { antenna: '#f472b6' }), + gizmoCyan: makeGoggle('#38bdf8', '#0369a1', { eyes: 1, antenna: '#f59e0b' }), }; export function Mascot({ diff --git a/src/routes/arcade/MathRunner.tsx b/src/routes/arcade/MathRunner.tsx index 2fc3046..0ffdf2a 100644 --- a/src/routes/arcade/MathRunner.tsx +++ b/src/routes/arcade/MathRunner.tsx @@ -20,7 +20,7 @@ const SESSION_SECONDS = 60; const FRUITS = ['🍎', '🍓', '🍌', '🍉', '🍇', '🍊', '🍑', '🥝', '🍒', '🥭'] as const; // Players pick which runner they want — all the new bold mascots. -const RUNNERS: MascotKind[] = ['ninja', 'panda', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', 'fox', 'cat']; +const RUNNERS: MascotKind[] = ['ninja', 'panda', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', 'fox', 'cat', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan']; type Scene = 'mountain' | 'city' | 'parking'; const SCENES: Scene[] = ['mountain', 'city', 'parking']; From 63bbc5e8886e8ef80ccfe7d84ae90b6a4b31c3da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 03:43:37 +0000 Subject: [PATCH 02/15] More inventory mascots: original racers + extra goggle-buddies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 original "Racer" mascots inspired by kart-racer art (a cap with a short brim + goggles pushed up on the forehead) — no logos or real-character likeness. racerRed, racerGreen, racerViolet. - Extend the Gizmo goggle-buddy family: gizmoSun (three-eyed), gizmoRose (spring antenna), gizmoSlate — the factory now supports 1/2/3 lenses and a springy antenna. - Register all in MascotKind + MASCOT_KINDS + PARTS and add them to the Math Runner and Lucky Crawl hero pickers so they're pullable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YJgCstFD5JgXpXb7CbsG5V --- src/routes/arcade/GreedyCrawler.tsx | 2 +- src/routes/arcade/Mascots.tsx | 65 ++++++++++++++++++++++++++--- src/routes/arcade/MathRunner.tsx | 2 +- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/routes/arcade/GreedyCrawler.tsx b/src/routes/arcade/GreedyCrawler.tsx index 3ab0166..c0ba880 100644 --- a/src/routes/arcade/GreedyCrawler.tsx +++ b/src/routes/arcade/GreedyCrawler.tsx @@ -15,7 +15,7 @@ import { sfx, haptic, HAPTIC } from '../../utils/arcadeAV'; // gamble stops being worth it. Trip the alarm and you lose the un-banked loot. const TARGET = 50; // gold you must BANK to win -const HEROES: MascotKind[] = ['fox', 'panda', 'bunny', 'penguin', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan']; +const HEROES: MascotKind[] = ['racerRed', 'racerGreen', 'racerViolet', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan', 'gizmoSun', 'gizmoRose', 'gizmoSlate', 'fox']; // reward for entering the n-th room of a run (1-based) — deeper = richer const rewardForRoom = (n: number) => 2 + 2 * n; diff --git a/src/routes/arcade/Mascots.tsx b/src/routes/arcade/Mascots.tsx index 4309874..e4ec4bf 100644 --- a/src/routes/arcade/Mascots.tsx +++ b/src/routes/arcade/Mascots.tsx @@ -38,7 +38,13 @@ export type MascotKind = | 'gizmoCoral' | 'gizmoViolet' | 'gizmoLime' - | 'gizmoCyan'; + | 'gizmoCyan' + | 'gizmoSun' + | 'gizmoRose' + | 'gizmoSlate' + | 'racerRed' + | 'racerGreen' + | 'racerViolet'; export type MascotExpr = 'happy' | 'surprised' | 'dizzy' | 'cheer' | 'ko'; @@ -48,6 +54,8 @@ export const MASCOT_KINDS: MascotKind[] = [ 'panda', 'ninja', 'clerk', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan', + 'gizmoSun', 'gizmoRose', 'gizmoSlate', + 'racerRed', 'racerGreen', 'racerViolet', ]; const OUTLINE = { stroke: '#1f2937', strokeWidth: 4, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const }; @@ -690,7 +698,7 @@ function makeCapsule(shell: string, shellDark: string, bow = false) { // pill body with a bolt antenna and a riveted goggle strap holding one or two // metal lenses. Bright non-yellow shells and a distinct tall silhouette keep them // clearly original. -function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2; antenna?: string } = {}) { +function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2 | 3; antenna?: string; spring?: boolean } = {}) { const eyes = opts.eyes ?? 2; const ant = opts.antenna ?? shellDark; const gid = `m-gg-${shell.slice(1)}`; @@ -719,9 +727,13 @@ function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2; ante - {/* antenna + bolt */} - - + {/* antenna + bolt (straight or springy) */} + {opts.spring ? ( + + ) : ( + + )} + {/* feet */} @@ -735,7 +747,11 @@ function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2; ante {/* lenses */} - {eyes === 1 ? lens(50, 9, e) : [lens(41, 7, e), lens(59, 7, e)]} + {eyes === 1 + ? lens(50, 9, e) + : eyes === 3 + ? [lens(35, 5.5, e), lens(50, 5.5, e), lens(65, 5.5, e)] + : [lens(41, 7, e), lens(59, 7, e)]} {/* mouth */} {e === 'cheer' ? ( @@ -750,6 +766,37 @@ function makeGoggle(shell: string, shellDark: string, opts: { eyes?: 1 | 2; ante ); } +// "Racer" buddies — ORIGINAL cartoon racers inspired by kart-racer art (cap with +// a short brim + goggles pushed up on the forehead). No logos or real-character +// likeness — just a friendly helmeted racer head in different team colors. +function makeRacer(cap: string, capDark: string, opts: { skin?: string } = {}) { + const skin = opts.skin ?? '#f4c8a1'; + return (e: MascotExpr) => ( + + {/* ears */} + + + {/* head */} + + {/* hair peeking at the sides/back */} + + + {/* cap dome + brim to the right */} + + + + {/* goggles pushed up on the forehead */} + + + + + + {/* face below the goggles */} + + + ); +} + // Chibi cow — tan head with pointy ears, little horns, a beige muzzle, blush and // a colourful cheek badge. (Inspired by a cow-mascot lineage; original art.) function Cow(e: MascotExpr) { @@ -841,6 +888,12 @@ const PARTS: Record JSX.Element> = { gizmoViolet: makeGoggle('#a78bfa', '#6d28d9', { antenna: '#34d399' }), gizmoLime: makeGoggle('#a3e635', '#4d7c0f', { antenna: '#f472b6' }), gizmoCyan: makeGoggle('#38bdf8', '#0369a1', { eyes: 1, antenna: '#f59e0b' }), + gizmoSun: makeGoggle('#fb923c', '#c2410c', { eyes: 3, antenna: '#22d3ee' }), + gizmoRose: makeGoggle('#f472b6', '#be185d', { antenna: '#a3e635', spring: true }), + gizmoSlate: makeGoggle('#94a3b8', '#475569', { antenna: '#f43f5e' }), + racerRed: makeRacer('#ef4444', '#b91c1c'), + racerGreen: makeRacer('#22c55e', '#15803d', { skin: '#e8b58a' }), + racerViolet: makeRacer('#a855f7', '#7e22ce', { skin: '#caa27a' }), }; export function Mascot({ diff --git a/src/routes/arcade/MathRunner.tsx b/src/routes/arcade/MathRunner.tsx index 0ffdf2a..5a69572 100644 --- a/src/routes/arcade/MathRunner.tsx +++ b/src/routes/arcade/MathRunner.tsx @@ -20,7 +20,7 @@ const SESSION_SECONDS = 60; const FRUITS = ['🍎', '🍓', '🍌', '🍉', '🍇', '🍊', '🍑', '🥝', '🍒', '🥭'] as const; // Players pick which runner they want — all the new bold mascots. -const RUNNERS: MascotKind[] = ['ninja', 'panda', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', 'fox', 'cat', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan']; +const RUNNERS: MascotKind[] = ['ninja', 'panda', 'redpanda', 'raccoon', 'turtle', 'shark', 'capsuleR', 'capsuleB', 'capsuleP', 'capsuleM', 'cow', 'bull', 'fox', 'cat', 'gizmoTeal', 'gizmoCoral', 'gizmoViolet', 'gizmoLime', 'gizmoCyan', 'gizmoSun', 'gizmoRose', 'gizmoSlate', 'racerRed', 'racerGreen', 'racerViolet']; type Scene = 'mountain' | 'city' | 'parking'; const SCENES: Scene[] = ['mountain', 'city', 'parking']; From 9735eac9c0a30522e0d55710f46550bbed4557c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 04:31:56 +0000 Subject: [PATCH 03/15] =?UTF-8?q?New=20game:=20Critter=20Cottage=20?= =?UTF-8?q?=E2=80=94=207-level=20carpentry=20(area=20&=20angles,=206.G)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mario-style thick-bordered carpentry builder that relates to the 6.G Geometry unit (answers recorded under '6.G'): - 7 levels assemble a 2-story cottage: floor (L×W), walls (L×W + 90°), trim & siding (SUBTRACT wall−window + 180°), roof (½·b·h triangle + 45°/15°), chimney + round window (rect + circle π·r², π≈3), yard/fence/mailbox (circle + 270°), then a Big Bad Wolf sturdiness test — survive and cute raccoons move in, with a house-warming bonus word problem for coins. - Doors + square window + round window included; the house SVG builds up piece-by-piece with bold outlines. - Tap answer chips, then SWIPE back-and-forth to saw each board (new sfx.saw/sfx.cut + buzzy haptics). - Drawers for reference & worksheets: a "📐 Guide" drawer (area formulas with worked examples + angle guide + scratchpad), plus per-question "📝 How to solve" (ProblemAid) and the header Help. - Registered (tile, route, mascot 'raccoon', how-to). New sfx.saw/sfx.cut in arcadeAV. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01YJgCstFD5JgXpXb7CbsG5V --- src/App.tsx | 2 + src/routes/arcade/CritterCottage.tsx | 513 +++++++++++++++++++++++++++ src/routes/arcade/Mascots.tsx | 1 + src/routes/arcade/howto.ts | 11 + src/routes/arcade/shared.tsx | 1 + src/utils/arcadeAV.ts | 12 + 6 files changed, 540 insertions(+) create mode 100644 src/routes/arcade/CritterCottage.tsx diff --git a/src/App.tsx b/src/App.tsx index a47710f..97cf3e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,7 @@ const FractionPizza = lazy(() => import('./routes/arcade/FractionPizza').then((m const ChessPuzzle = lazy(() => import('./routes/arcade/ChessPuzzle').then((m) => ({ default: m.ChessPuzzle }))); const ChineseCheckers = lazy(() => import('./routes/arcade/ChineseCheckers').then((m) => ({ default: m.ChineseCheckers }))); const GreedyCrawler = lazy(() => import('./routes/arcade/GreedyCrawler').then((m) => ({ default: m.GreedyCrawler }))); +const CritterCottage = lazy(() => import('./routes/arcade/CritterCottage').then((m) => ({ default: m.CritterCottage }))); const NotFound = lazy(() => import('./routes/NotFound').then((m) => ({ default: m.NotFound }))); // Warm-up gate wraps every arcade game with a short adaptive quiz. @@ -132,6 +133,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/routes/arcade/CritterCottage.tsx b/src/routes/arcade/CritterCottage.tsx new file mode 100644 index 0000000..9003351 --- /dev/null +++ b/src/routes/arcade/CritterCottage.tsx @@ -0,0 +1,513 @@ +import { useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useProgress, type ArcadePlayOutcome } from '../../state/progress'; +import { ArcadeHeader, ArcadeEndCard } from './shared'; +import { useArcadeClock } from '../../hooks/useArcadeClock'; +import { Mascot as CharMascot, type MascotKind, type MascotExpr } from './Mascots'; +import { MilestoneQuiz } from './MilestoneQuiz'; +import { ProblemAidDrawer } from './ProblemAid'; +import { sfx, haptic, HAPTIC } from '../../utils/arcadeAV'; + +// Critter Cottage — a 7-level carpentry builder that teaches AREA (rectangle L×W, +// triangle ½·b·h, circle π·r²), AREA SUBTRACTION (siding = wall − window), and +// ANGLES (90/180/45/15/270). Mario-style thick borders. You measure (tap a chip), +// then SWIPE to saw the board, and the piece snaps onto the cottage. A Big Bad +// Wolf tests the build's sturdiness, then cute raccoons move in. Relates to the +// 6.G Geometry unit (answers recorded under '6.G'). + +const BUILDERS: MascotKind[] = ['raccoon', 'fox', 'bull', 'panda', 'turtle', 'cow']; +const STROKES_NEEDED = 3; +// Circle answers use a kid-friendly π ≈ 3 (real π ≈ 3.14, noted in the help drawer). + +interface QA { + prompt: string; + answer: number; + choices: number[]; + unit: string; // 'sq units' | '°' + aid?: string; // prompt handed to ProblemAidDrawer (rect/triangle auto-explained) + hint?: string; // inline hint for circle / angle / subtraction +} +interface Phase { + key: string; + title: string; + emoji: string; + wolf?: boolean; + steps: QA[]; +} + +const PHASES: Phase[] = [ + { + key: 'floor', title: 'Foundation & Floor', emoji: '🪵', + steps: [ + { prompt: 'Measure the floor: length 6, width 4.\nArea = length × width = ?', answer: 24, choices: [20, 24, 28, 32], unit: 'sq units', aid: 'A rectangle is 6 wide and 4 tall. What is its AREA?' }, + ], + }, + { + key: 'walls', title: 'Two-Story Walls', emoji: '🧱', + steps: [ + { prompt: 'A tall wall is 5 wide and 8 high.\nArea = length × width = ?', answer: 40, choices: [40, 13, 35, 45], unit: 'sq units', aid: 'A rectangle is 5 wide and 8 tall. What is its AREA?' }, + { prompt: 'Every corner of the frame is a square corner.\nWhat angle is a square corner?', answer: 90, choices: [45, 90, 180, 30], unit: '°', hint: 'A square (right-angle) corner is exactly 90°.' }, + ], + }, + { + key: 'trim', title: 'Trim & Siding', emoji: '📐', + steps: [ + { prompt: 'Siding covers the wall EXCEPT the window.\nWall 8×5 = 40, window 2×3 = 6.\nSiding = 40 − 6 = ?', answer: 34, choices: [34, 46, 32, 40], unit: 'sq units', hint: 'Find each area, then SUBTRACT: 40 − 6 = 34. Leftover is what you cover.' }, + { prompt: 'The window sill lies perfectly flat — a straight line.\nA straight line is what angle?', answer: 180, choices: [90, 120, 180, 270], unit: '°', hint: 'A straight line is a half turn = 180°.' }, + ], + }, + { + key: 'roof', title: 'Roof', emoji: '🔺', + steps: [ + { prompt: 'The triangle roof face has base 8, height 5.\nArea = ½ × base × height = ?', answer: 20, choices: [40, 20, 13, 26], unit: 'sq units', aid: 'A triangle has base 8 and height 5. Its area = ½ × base × height = ?' }, + { prompt: 'Set the saw for the roof pitch — a nice slanted cut.\nWhich is the 45° pitch angle?', answer: 45, choices: [15, 45, 90, 180], unit: '°', hint: 'A 45° cut is halfway between flat (0°) and straight-up (90°).' }, + { prompt: 'Trim the eave with a gentle, shallow cut.\nWhich is the smallest, gentlest angle?', answer: 15, choices: [15, 45, 90, 270], unit: '°', hint: 'The smallest angle here is 15° — barely a tilt.' }, + ], + }, + { + key: 'chimney', title: 'Chimney & Round Window', emoji: '🪟', + steps: [ + { prompt: 'Build the chimney: 2 wide, 6 tall.\nArea = length × width = ?', answer: 12, choices: [8, 12, 16, 10], unit: 'sq units', aid: 'A rectangle is 2 wide and 6 tall. What is its AREA?' }, + { prompt: 'Cut a ROUND window, radius 4.\nCircle area = π × r × r (use π ≈ 3) = ?', answer: 48, choices: [24, 48, 12, 64], unit: 'sq units', hint: 'π × r × r ≈ 3 × 4 × 4 = 48. (Real π ≈ 3.14.)' }, + ], + }, + { + key: 'yard', title: 'Yard, Fence & Mailbox', emoji: '🏡', + steps: [ + { prompt: 'Lay a ROUND yard, radius 6.\nCircle area = π × r × r (use π ≈ 3) = ?', answer: 108, choices: [108, 36, 96, 120], unit: 'sq units', hint: 'π × r × r ≈ 3 × 6 × 6 = 108.' }, + { prompt: 'Spin the mailbox flag a three-quarter turn.\nHow many degrees is ¾ of a full 360° turn?', answer: 270, choices: [180, 240, 270, 300], unit: '°', hint: '¾ × 360 = 270°. (A full turn is 360°.)' }, + ], + }, + { + key: 'wolf', title: 'Big Bad Wolf!', emoji: '🐺', wolf: true, + steps: [ + { prompt: 'The Big Bad Wolf huffs and puffs! 💨\nStrong walls meet at sturdy SQUARE corners.\nWhat angle makes the sturdiest corner?', answer: 90, choices: [15, 45, 90, 180], unit: '°', hint: 'Square corners (90°) brace the frame — the sturdiest build!' }, + ], + }, +]; + +// which house pieces are visible = number of fully-built phases +function House({ built, wolf, movedIn, hero }: { built: number; wolf: boolean; movedIn: boolean; hero: MascotKind }) { + const S = { stroke: '#0f172a', strokeWidth: 4, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const }; + const show = (n: number) => built >= n; // n = 1-based phase number + return ( + + {/* sky/ground */} + + + + + {/* yard + fence + mailbox (phase 6) */} + {show(6) && ( + + + + {/* mailbox */} + + + + + )} + + {/* floor slab (phase 1) */} + {show(1) && } + + {/* two-storey walls (phase 2) */} + {show(2) && ( + + + + + )} + + {/* siding trim: door + square window opening (phase 3) */} + {show(3) && ( + + + + + + )} + + {/* roof gable + rafters (phase 4) */} + {show(4) && ( + + + + + + )} + + {/* chimney + round window (phase 5) */} + {show(5) && ( + + + + + + + )} + + {/* the Big Bad Wolf huffing (phase 7, until moved in) */} + {wolf && !movedIn && ( + + 🐺 + 💨 + + )} + + {/* raccoons moved in — peeking from the door/window */} + {movedIn && ( + <> + + + ❤️ + + )} + + ); +} + +// Swipe (or tap) the plank back and forth to saw it. Each stroke rasps + buzzes; +// a few strokes cut it clean. +function SawBar({ onDone, buzz }: { onDone: () => void; buzz: (p: number | number[]) => void }) { + const [strokes, setStrokes] = useState(0); + const activeRef = useRef(false); + const lastX = useRef(null); + const lastDir = useRef(0); + const doneRef = useRef(false); + + const stroke = () => { + if (doneRef.current) return; + setStrokes((s) => { + const n = s + 1; + if (n >= STROKES_NEEDED) { + doneRef.current = true; + sfx.cut(); buzz(HAPTIC.heavy); + window.setTimeout(onDone, 260); + } else { + sfx.saw(); buzz([0, 40, 15, 40]); + } + return n; + }); + }; + + const onMove = (x: number) => { + if (!activeRef.current || doneRef.current) return; + if (lastX.current == null) { lastX.current = x; return; } + const dx = x - lastX.current; + if (Math.abs(dx) < 14) return; + const dir = Math.sign(dx); + if (dir !== 0 && dir !== lastDir.current) { + lastDir.current = dir; + stroke(); + } + lastX.current = x; + }; + + const pct = Math.min(100, (strokes / STROKES_NEEDED) * 100); + return ( +
+
🪚 Swipe back & forth to saw the board!
+
{ activeRef.current = true; lastX.current = e.clientX; }} + onPointerMove={(e) => onMove(e.clientX)} + onPointerUp={() => { activeRef.current = false; lastX.current = null; }} + onPointerLeave={() => { activeRef.current = false; lastX.current = null; }} + onClick={stroke} + role="button" + aria-label="Saw the board" + > + {/* wood grain */} +
+ {/* dashed cut line */} +
+ {/* the saw, sliding with progress */} +
🪚
+
stroke {Math.min(strokes, STROKES_NEEDED)} / {STROKES_NEEDED}
+
+
+ ); +} + +// Pull-up REFERENCE + WORKSHEET drawer: the area formulas (with worked examples), +// the angle guide, and a scratchpad to work problems out. Always available. +function GuideDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { + const [scratch, setScratch] = useState(''); + const AREAS: [string, string, string][] = [ + ['▭ Rectangle', 'Area = length × width', 'e.g. 6 × 4 = 24'], + ['🔺 Triangle (roof)', 'Area = ½ × base × height', 'e.g. ½ × 8 × 5 = 20'], + ['⭕ Circle (window/yard)', 'Area = π × r × r (π ≈ 3)', 'e.g. 3 × 4 × 4 = 48'], + ['✂️ Trim / siding', 'SUBTRACT: wall − window', 'e.g. 40 − 6 = 34'], + ]; + const ANGLES: [string, string][] = [ + ['15°', 'a gentle tilt — the eave trim'], + ['45°', 'roof pitch — half of a right angle'], + ['90°', 'a square corner (right angle)'], + ['180°', 'a straight line — a half turn'], + ['270°', 'a three-quarter turn'], + ]; + return ( + + {open && ( + <> + + +
+
+

📐 Builder's guide

+ +
+ +
Area formulas
+
+ {AREAS.map(([t, f, ex]) => ( +
+
{t}
+
{f}
+
{ex}
+
+ ))} +
+ +
Angle guide
+
+ {ANGLES.map(([a, d]) => ( +
+ {a} + {d} +
+ ))} +
+ +
+
✏️ Worksheet — work it out here
+