diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..0d791c7a2ae --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(pnpm --filter perseus test:*)", + "Bash(pnpm test:*)", + "Bash(git mv:*)", + "Bash(for file in sky.png city-far.png city-semi-far.png city-semi-close.png city-close.png streetlamp.png lamplight.png)", + "Bash(do git mv packages/perseus/src/__docs__/$file packages/perseus/src/games/crash-course/assets/backgrounds/)", + "Bash(done)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CAR_BONUS_CONCEPT.md b/CAR_BONUS_CONCEPT.md new file mode 100644 index 00000000000..f14daffcee8 --- /dev/null +++ b/CAR_BONUS_CONCEPT.md @@ -0,0 +1,78 @@ +# Concept: Car Bonus Scene (Game Over Sequence) + +## What We're Building + +A comedic "bonus level" that plays when the user runs out of lives in the Math Blaster game. Inspired by the Street Fighter 2 car-smashing bonus level, but with a twist: the car self-destructs without any user interaction. It's a quick visual gag that serves as a humorous transition to the game over screen. + +## Why + +- **Add personality**: Makes the game over experience more memorable and fun +- **Reduce frustration**: Lightens the mood when players lose all their lives +- **Homage**: A playful reference to a classic video game moment +- **Polish**: Adds a unique touch that makes the Math Blaster game feel more complete + +## Key Components + +1. **Car Bonus Scene Component**: A new React component that displays the car sequence + - Shows intact car (initial state) + - Transitions to destroyed car (imploded state) + - Simple two-frame "animation" (before → after) + +2. **Transition Logic**: Integration into existing game flow + - Triggered when lives reach zero + - Smoothly transitions from main game to car scene + - After completion, proceeds to existing game over screen + +3. **Visual Assets**: Two car images + - Before: Intact car + - After: Imploded/destroyed car + +4. **Timing System**: Controls the sequence duration + - Total duration: 3-5 seconds + - Possible breakdown: + - Show intact car: ~1-2 seconds + - Implode transition: ~0.5 seconds + - Show destroyed car: ~1-2 seconds + - Optional: Brief shake/anticipation effect before implode + +## How It Works (High Level) + +**User Flow:** + +1. Player is playing an endless runner type game with math questions +2. Player loses their last life +3. Game transitions to car bonus scene +4. Car appears intact on screen +5. Brief pause (build anticipation) +6. Car suddenly implodes (image switches from before → after) +7. Brief pause (appreciate the destruction) +8. Transition to existing game over screen + +**No user interaction required** - it's a passive, timed sequence. + +## Technical Considerations + +- **React Component**: Create a self-contained component for the car scene +- **State Management**: Needs to know when game is over (lives === 0) +- **Timing**: Use CSS transitions or React state + setTimeout for image swap +- **Assets**: Need to source/create the two car images (before/after) +- **Integration Point**: Hook into existing game over logic in Math Blaster +- **Storybook Story**: Create a story to demo and test the sequence in isolation + +## Success Criteria + +- [ ] Car bonus scene triggers when player runs out of lives +- [ ] Sequence displays intact car, then destroyed car +- [ ] Total duration is 3-5 seconds +- [ ] After sequence completes, game over screen appears +- [ ] Transition feels smooth and polished +- [ ] Works in Storybook for easy demonstration +- [ ] Visual gag lands (it's funny!) +- [ ] No user interaction required (truly passive) + +## Open Questions + +- Should there be sound effects? (Currently not planned, but could add later) +- Do we want any text on screen during the sequence? (e.g., "BONUS!" or "NICE TRY!") +- Should there be any particle effects or additional visual polish? +- What should the background look like? (Solid color, street scene, etc.) diff --git a/CAR_BONUS_PLAN.md b/CAR_BONUS_PLAN.md new file mode 100644 index 00000000000..440c5ecdf57 --- /dev/null +++ b/CAR_BONUS_PLAN.md @@ -0,0 +1,33 @@ +# Implementation Plan: Car Bonus Scene + +Based on: [CAR_BONUS_CONCEPT.md](./CAR_BONUS_CONCEPT.md) + +## Implementation Tasks + +1. **Create CarBonusScene component** (`packages/perseus/src/__docs__/car-bonus-scene.tsx`) + - Functional component with `onComplete` callback prop + - State management for intact → destroyed → complete + - Use setTimeout for 3-5 second timing + - Two car images (before/after) + - Basic CSS styling (centered, responsive) + +2. **Create Storybook story** (`packages/perseus/src/__docs__/car-bonus-scene.stories.tsx`) + - Demo the standalone component + - Test timing and transitions in isolation + +3. **Integrate into Math Blaster game** + - Add car bonus scene state to game flow + - Trigger when lives reach 0 + - Transition: game → car scene → game over screen + +4. **Polish** + - Smooth fade transitions + - Fine-tune timing + - Test on different screen sizes + - Run lint/format/type-check + +## Notes + +- Start with placeholder images if needed +- Keep it simple - just two images and timing +- Total estimated time: 2-4 hours diff --git a/PHASE_1_setup_assets.md b/PHASE_1_setup_assets.md new file mode 100644 index 00000000000..bb2f75413af --- /dev/null +++ b/PHASE_1_setup_assets.md @@ -0,0 +1,313 @@ +# Phase 1: Setup and Asset Organization + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Create the new directory structure and organize all 40+ asset files into proper subdirectories with consistent naming. + +## Tasks + +### Task 1: Create Directory Structure +- **What**: Set up the new `games/crash-course/` folder hierarchy +- **Why**: Establishes the foundation for all subsequent work +- **Implementation notes**: + - Create base directory at `packages/perseus/src/games/crash-course/` + - Create asset subdirectories for organization + - Create placeholder files to prevent empty directory issues +- **Files affected**: + - New: `packages/perseus/src/games/crash-course/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/sprites/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/backgrounds/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/ui/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/audio/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/story/` (directory) +- **Acceptance criteria**: + - Directory structure exists + - All asset subdirectories created + - Structure follows Perseus conventions + +### Task 2: Move and Organize Sprite Assets +- **What**: Move character, alien, and car sprites to the sprites folder +- **Why**: Groups related visual assets together +- **Implementation notes**: + - Move character animation frames (run1-6, impact, idle) + - Move alien sprites (alien1-3) + - Move car sprites (car1-2) + - Move special effects (beam) + - Keep original names or use kebab-case consistently +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/sprites/` + - Specific files: + - `run1.png` → `sprites/run1.png` + - `run2.png` → `sprites/run2.png` + - `run3.png` → `sprites/run3.png` + - `run4.png` → `sprites/run4.png` + - `run5.png` → `sprites/run5.png` + - `run6.png` → `sprites/run6.png` + - `impact.png` → `sprites/impact.png` + - `idle.png` → `sprites/idle.png` + - `alien1.png` → `sprites/alien1.png` + - `alien2.png` → `sprites/alien2.png` + - `alien3.png` → `sprites/alien3.png` + - `car1.png` → `sprites/car1.png` + - `car2.png` → `sprites/car2.png` + - `beam.png` → `sprites/beam.png` +- **Acceptance criteria**: + - All sprite files moved to correct directory + - No duplicate files remain in __docs__ + +### Task 3: Move and Organize Background Assets +- **What**: Move parallax layers and environment graphics +- **Why**: Separates background elements from interactive sprites +- **Implementation notes**: + - Move sky, city layers, and ground elements + - Move street lamp and lamp light +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/backgrounds/` + - Specific files: + - `sky.png` → `backgrounds/sky.png` + - `city-far.png` → `backgrounds/city-far.png` + - `city-semi-far.png` → `backgrounds/city-semi-far.png` + - `city-semi-close.png` → `backgrounds/city-semi-close.png` + - `city-close.png` → `backgrounds/city-close.png` + - `streetlamp.png` → `backgrounds/streetlamp.png` + - `lamplight.png` → `backgrounds/lamplight.png` +- **Acceptance criteria**: + - All background files moved + - Logical organization by depth/layer + +### Task 4: Move and Organize UI Assets +- **What**: Move buttons, screens, and UI elements +- **Why**: Keeps user interface assets separate from game graphics +- **Implementation notes**: + - Move all button images (start, next, mute, unmute) + - Move screen images (title, victory, lose) + - Move bonus scene images +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/ui/` + - Specific files: + - `title.png` → `ui/title.png` + - `start.png` → `ui/start.png` + - `next.png` → `ui/next.png` + - `mute.png` → `ui/mute.png` + - `unmute.png` → `ui/unmute.png` + - `victory.png` → `ui/victory.png` + - `lose.png` → `ui/lose.png` + - `bonus1.png` → `ui/bonus1.png` + - `bonus2.png` → `ui/bonus2.png` + - `skid.png` → `ui/skid.png` +- **Acceptance criteria**: + - All UI assets moved + - Easy to find buttons vs screens + +### Task 5: Move and Organize Story Assets +- **What**: Move all 7 story page images +- **Why**: Keeps narrative content separate and organized +- **Implementation notes**: + - Move story1 through story7 images + - Keep sequential naming for easy identification +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/story*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/story/` + - Specific files: + - `story1.png` → `story/story1.png` + - `story2.png` → `story/story2.png` + - `story3.png` → `story/story3.png` + - `story4.png` → `story/story4.png` + - `story5.png` → `story/story5.png` + - `story6.png` → `story/story6.png` + - `story7.png` → `story/story7.png` +- **Acceptance criteria**: + - All story images in story/ folder + - Sequential numbering preserved + +### Task 6: Move and Organize Audio Assets +- **What**: Move all audio files (.ogg) +- **Why**: Separates audio from visual assets +- **Implementation notes**: + - Move all 4 audio files + - Consider renaming for clarity (optional) +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.ogg` + - Move to: `packages/perseus/src/games/crash-course/assets/audio/` + - Specific files: + - `alexbouncymix2.ogg` → `audio/menu-theme.ogg` (or keep original name) + - `Zodik - Tedox.ogg` → `audio/game-theme-1.ogg` (or keep original) + - `Zodik - Neon Owl.ogg` → `audio/game-theme-2.ogg` (or keep original) + - `Game Over II.ogg` → `audio/game-over-theme.ogg` (or keep original) +- **Acceptance criteria**: + - All audio files moved + - Names are clear and descriptive + +### Task 7: Move TypeScript/CSS Files +- **What**: Move the game components and utilities to the new location +- **Why**: Consolidates all game code in one place +- **Implementation notes**: + - Move main story file, utils, CSS, and car bonus scene + - Rename files from "math-blaster" to "crash-course" + - Update internal references +- **Files affected**: + - Move and rename: + - `__docs__/math-blaster-game.stories.tsx` → `games/crash-course/crash-course.stories.tsx` + - `__docs__/math-blaster-game.module.css` → `games/crash-course/crash-course.module.css` + - `__docs__/math-blaster-utils.ts` → `games/crash-course/crash-course-utils.ts` + - `__docs__/car-bonus-scene.tsx` → `games/crash-course/car-bonus-scene.tsx` + - `__docs__/car-bonus-scene.module.css` → `games/crash-course/car-bonus-scene.module.css` + - `__docs__/car-bonus-scene.stories.tsx` → `games/crash-course/car-bonus-scene.stories.tsx` (or delete if not needed) +- **Acceptance criteria**: + - All code files in games/crash-course/ + - Consistent "crash-course" naming + - No "math-blaster" references in filenames + +### Task 8: Update Import Paths in Main Component +- **What**: Update all asset import statements to point to new locations +- **Why**: Make the code work after moving assets +- **Implementation notes**: + - Update image imports to use new asset paths + - Update audio imports to use new paths + - Update relative imports for CSS and utilities + - Search for `from "./` and update paths +- **Files affected**: + - `games/crash-course/crash-course.stories.tsx` (main file) + - `games/crash-course/car-bonus-scene.tsx` +- **Acceptance criteria**: + - All imports resolve correctly + - No import errors when building + - TypeScript doesn't complain + +### Task 9: Update Import Paths in CarBonusScene +- **What**: Update asset imports in the CarBonusScene component +- **Why**: Ensure CarBonusScene works after asset reorganization +- **Implementation notes**: + - Update any image imports (bonus1, bonus2, skid) + - Update CSS import path if needed +- **Files affected**: + - `games/crash-course/car-bonus-scene.tsx` +- **Acceptance criteria**: + - CarBonusScene imports work + - No broken image references + +### Task 10: Update Storybook Story Metadata +- **What**: Change Storybook title and component references +- **Why**: Make it appear under "Games" section with correct name +- **Implementation notes**: + - Change `title: "Games/Crash Course"` in story metadata + - Update component name references + - Update description to reflect new name +- **Files affected**: + - `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story appears under "Games" in Storybook + - Title is "Crash Course" not "Math Blaster" + - Description is accurate + +## Technical Details + +### Import Path Examples + +**Before (in __docs__):** +```typescript +import run1Img from "./run1.png"; +import styles from "./math-blaster-game.module.css"; +``` + +**After (in games/crash-course/):** +```typescript +import run1Img from "./assets/sprites/run1.png"; +import styles from "./crash-course.module.css"; +``` + +### Directory Structure Result +``` +packages/perseus/src/games/crash-course/ +├── assets/ +│ ├── sprites/ +│ │ ├── run1.png +│ │ ├── run2.png +│ │ ├── run3.png +│ │ ├── run4.png +│ │ ├── run5.png +│ │ ├── run6.png +│ │ ├── impact.png +│ │ ├── idle.png +│ │ ├── alien1.png +│ │ ├── alien2.png +│ │ ├── alien3.png +│ │ ├── car1.png +│ │ ├── car2.png +│ │ └── beam.png +│ ├── backgrounds/ +│ │ ├── sky.png +│ │ ├── city-far.png +│ │ ├── city-semi-far.png +│ │ ├── city-semi-close.png +│ │ ├── city-close.png +│ │ ├── streetlamp.png +│ │ └── lamplight.png +│ ├── ui/ +│ │ ├── title.png +│ │ ├── start.png +│ │ ├── next.png +│ │ ├── mute.png +│ │ ├── unmute.png +│ │ ├── victory.png +│ │ ├── lose.png +│ │ ├── bonus1.png +│ │ ├── bonus2.png +│ │ └── skid.png +│ ├── story/ +│ │ ├── story1.png +│ │ ├── story2.png +│ │ ├── story3.png +│ │ ├── story4.png +│ │ ├── story5.png +│ │ ├── story6.png +│ │ └── story7.png +│ └── audio/ +│ ├── menu-theme.ogg (or original name) +│ ├── game-theme-1.ogg +│ ├── game-theme-2.ogg +│ └── game-over-theme.ogg +├── crash-course.stories.tsx (main game component) +├── crash-course.module.css +├── crash-course-utils.ts +├── car-bonus-scene.tsx +└── car-bonus-scene.module.css +``` + +## Testing Considerations + +After this phase: +1. **Build Check**: Run `pnpm tsc` - should have no errors +2. **Lint Check**: Run `pnpm lint` - should pass +3. **Storybook Check**: + - Run `pnpm storybook` + - Navigate to "Games/Crash Course" + - Verify game loads and displays start screen + - All images should load correctly + - Audio should be available (test mute button) + +## Potential Issues + +### Import Resolution +- **Issue**: Webpack/Vite may need configuration to resolve asset imports +- **Mitigation**: Perseus likely already configured for this, but verify +- **How to detect**: Build errors about unresolved imports + +### Asset Loading +- **Issue**: Some assets might not load if paths are incorrect +- **Mitigation**: Systematic testing of each screen +- **How to detect**: Console errors, broken images in Storybook + +### File System Case Sensitivity +- **Issue**: macOS is case-insensitive but Linux is case-sensitive +- **Mitigation**: Use consistent casing (lowercase with hyphens) +- **How to detect**: Works locally but fails in CI + +### Git History +- **Issue**: Moving files loses git history +- **Mitigation**: Use `git mv` instead of regular `mv` to preserve history +- **How to detect**: Check `git log --follow` on moved files diff --git a/PHASE_2_ui_components.md b/PHASE_2_ui_components.md new file mode 100644 index 00000000000..5d7168b684f --- /dev/null +++ b/PHASE_2_ui_components.md @@ -0,0 +1,348 @@ +# Phase 2: Extract UI Components + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Break out all screen and overlay components from the monolithic 1673-line file into separate, focused components. + +## Tasks + +### Task 1: Create Types File +- **What**: Extract all TypeScript types and interfaces into a shared types file +- **Why**: Allows components to import types without circular dependencies +- **Implementation notes**: + - Create `types.ts` in crash-course directory + - Export all game-related types: GameState, CharacterState, SpriteFrame, Obstacle + - Import from crash-course-utils if needed +- **Files affected**: + - New: `games/crash-course/types.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - All types exported from central location + - No type duplication between files + - TypeScript compilation succeeds + +### Task 2: Extract StartScreen Component +- **What**: Create standalone StartScreen component +- **Why**: Reduces main file by ~50 lines, makes start screen independently testable +- **Implementation notes**: + - Extract JSX from gameState === "start" block + - Accept props: imagesLoaded, titleImage, startButton, onStart + - Handle loading state internally + - Apply styles from crash-course.module.css +- **Files affected**: + - New: `games/crash-course/components/StartScreen.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` (remove inline JSX) +- **Acceptance criteria**: + - StartScreen renders independently + - Start button click triggers callback + - Loading state displayed correctly + - Images load properly + +### Task 3: Extract StoryScreen Component +- **What**: Create standalone StoryScreen component with page navigation +- **Why**: Isolates story functionality, makes it reusable +- **Implementation notes**: + - Extract JSX from gameState === "story" block + - Accept props: currentPage (1-7), storyImages, nextButton, startButton, onNext + - Handle page transitions + - Show appropriate button (next vs start) based on page +- **Files affected**: + - New: `games/crash-course/components/StoryScreen.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story pages display correctly + - Page navigation works + - Final page shows start button + - Images load for all 7 pages + +### Task 4: Extract GameOverScreen Component +- **What**: Create standalone game over screen component +- **Why**: Separates end state UI from main game logic +- **Implementation notes**: + - Extract JSX from gameState === "gameover" block + - Accept props: loseImage, startButton, onPlayAgain + - Simple presentation component +- **Files affected**: + - New: `games/crash-course/components/GameOverScreen.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Game over screen displays + - Play again button works + - Styling preserved + +### Task 5: Extract VictoryScreen Component +- **What**: Create standalone victory screen component +- **Why**: Separates victory UI from main game +- **Implementation notes**: + - Extract JSX from gameState === "victory" block + - Accept props: victoryImage, startButton, onPlayAgain + - Simple presentation component +- **Files affected**: + - New: `games/crash-course/components/VictoryScreen.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Victory screen displays + - Play again button works + - Styling preserved + +### Task 6: Extract HUD Component +- **What**: Create heads-up display component showing score, time, lives +- **Why**: Separates UI overlay from game canvas logic +- **Implementation notes**: + - Extract JSX from gameState === "playing" HUD blocks + - Accept props: score, gameTime, lives + - Include the HUD background gradient + - Self-contained styling +- **Files affected**: + - New: `games/crash-course/components/HUD.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - HUD displays all info correctly + - Lives indicators show/fade properly + - Background gradient applies + - Positioning correct + +### Task 7: Extract QuestionOverlay Component +- **What**: Create component for displaying Perseus questions with timer +- **Why**: Complex component deserves its own file, easier to maintain +- **Implementation notes**: + - Extract JSX from Question Overlay block (lines ~1396-1481) + - Accept props: obstacle, remainingDistance, onCheckAnswer, answerFeedback + - Include timer bar logic and calculation + - Include ServerItemRenderer integration + - Forward ref to itemRenderer +- **Files affected**: + - New: `games/crash-course/components/QuestionOverlay.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Questions display correctly + - Timer bar animates properly + - Check answer button works + - Feedback displays correctly + - Perseus integration maintained + +### Task 8: Extract MuteButton Component +- **What**: Create simple mute/unmute button component +- **Why**: Reusable UI element, simple extraction +- **Implementation notes**: + - Extract JSX from mute button block + - Accept props: isMuted, muteImage, unmuteImage, onToggle + - Simple presentational component +- **Files affected**: + - New: `games/crash-course/components/MuteButton.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Button displays correct icon + - Click toggles mute state + - Positioning preserved + - Hover effects work + +### Task 9: Extract BenevolenceMessage Component +- **What**: Create component for "BENEVOLENCE" message animation +- **Why**: Self-contained animation, can be reused +- **Implementation notes**: + - Extract JSX from benevolenceMessage block + - Accept props: show (boolean) + - Handle animation automatically when shown + - Include CSS animation +- **Files affected**: + - New: `games/crash-course/components/BenevolenceMessage.tsx` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Message displays when triggered + - Animation plays correctly + - Fades out after 2 seconds + - Centered properly + +### Task 10: Create Components Directory and Index +- **What**: Organize all components in a subdirectory with barrel export +- **Why**: Clean imports, clear organization +- **Implementation notes**: + - Create `games/crash-course/components/` directory + - Move all component files into components/ + - Create `index.ts` with re-exports + - Update imports in main file +- **Files affected**: + - New: `games/crash-course/components/index.ts` + - All component files moved to components/ + - Modified: `games/crash-course/crash-course.stories.tsx` (update imports) +- **Acceptance criteria**: + - All components in components/ folder + - Barrel export works + - Main file imports simplified + +### Task 11: Update Main Component to Use Extracted Components +- **What**: Replace inline JSX with component imports +- **Why**: Dramatically reduces main file size +- **Implementation notes**: + - Replace each game state's inline JSX with component usage + - Pass appropriate props to each component + - Maintain existing behavior + - Verify callbacks work correctly +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Main file reduced by 400-500 lines + - All screens still work + - No functionality lost + - Props passed correctly + +## Technical Details + +### Component Props Pattern + +**Example: StartScreen** +```typescript +// components/StartScreen.tsx +import * as React from "react"; +import styles from "../crash-course.module.css"; + +type StartScreenProps = { + imagesLoaded: boolean; + titleImage?: HTMLImageElement; + startButton?: HTMLImageElement; + onStart: () => void; +}; + +export const StartScreen = ({ + imagesLoaded, + titleImage, + startButton, + onStart, +}: StartScreenProps): React.ReactElement => { + if (!imagesLoaded) { + return ( +
+

Loading sprites...

+
+ ); + } + + return ( +
+ {titleImage && ( + Grand Khan Auto + )} + {startButton && ( + Start Game + )} +
+ ); +}; +``` + +### Barrel Export Pattern + +**components/index.ts** +```typescript +export {StartScreen} from "./StartScreen"; +export {StoryScreen} from "./StoryScreen"; +export {GameOverScreen} from "./GameOverScreen"; +export {VictoryScreen} from "./VictoryScreen"; +export {HUD} from "./HUD"; +export {QuestionOverlay} from "./QuestionOverlay"; +export {MuteButton} from "./MuteButton"; +export {BenevolenceMessage} from "./BenevolenceMessage"; +``` + +### Usage in Main Component + +**Before:** +```typescript +{gameState === "start" && ( +
+ {!imagesLoaded &&

Loading sprites...

} + {imagesLoaded && (/* 20+ lines of JSX */)} +
+)} +``` + +**After:** +```typescript +import {StartScreen} from "./components"; + +{gameState === "start" && ( + +)} +``` + +## Component Organization + +``` +games/crash-course/ +├── components/ +│ ├── StartScreen.tsx (~40 lines) +│ ├── StoryScreen.tsx (~50 lines) +│ ├── GameOverScreen.tsx (~35 lines) +│ ├── VictoryScreen.tsx (~35 lines) +│ ├── HUD.tsx (~60 lines) +│ ├── QuestionOverlay.tsx (~120 lines) +│ ├── MuteButton.tsx (~25 lines) +│ ├── BenevolenceMessage.tsx (~20 lines) +│ └── index.ts (~10 lines) +├── crash-course.stories.tsx (now ~1200 lines, down from 1673) +└── ... +``` + +## Testing Considerations + +After this phase: +1. **Visual Testing**: Each component should display correctly in Storybook +2. **Interaction Testing**: + - Start screen → Story → Game → End screens flow + - Buttons trigger correct callbacks + - Question overlay displays and accepts answers + - HUD updates correctly +3. **Regression Testing**: Game plays identically to before + +## Potential Issues + +### Prop Drilling +- **Issue**: May need to pass many props through components +- **Mitigation**: Use composition, consider context for deeply nested state +- **How to detect**: Components with 10+ props + +### Ref Forwarding +- **Issue**: QuestionOverlay needs ref to ServerItemRenderer +- **Mitigation**: Use React.forwardRef properly +- **How to detect**: getUserInput() calls fail + +### CSS Module Scope +- **Issue**: Components need access to parent CSS module +- **Mitigation**: Import from parent, or create component-specific styles +- **How to detect**: Missing styles in components + +### State Management +- **Issue**: Components need access to game state +- **Mitigation**: Pass state as props, prepare for Phase 4 refactor +- **How to detect**: Components can't access needed state + +## Benefits After This Phase + +- Main component reduced from 1673 to ~1200 lines +- 8 new reusable, testable components +- Clearer separation of concerns +- Easier to understand code structure +- Individual components can be documented/tested +- Foundation for Phase 3 (logic extraction) diff --git a/PHASE_3_game_logic.md b/PHASE_3_game_logic.md new file mode 100644 index 00000000000..9f0ad7389cc --- /dev/null +++ b/PHASE_3_game_logic.md @@ -0,0 +1,535 @@ +# Phase 3: Extract Game Logic and Hooks + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Move game loop, audio management, sprite loading, and utilities into custom hooks and utility functions. + +## Tasks + +### Task 1: Create Constants File +- **What**: Extract all magic numbers and configuration into a constants file +- **Why**: Centralized configuration, easier to tune game behavior +- **Implementation notes**: + - Extract all CAPS constants from main file + - Group by category (canvas, physics, timing, spawning) + - Export as named constants +- **Files affected**: + - New: `games/crash-course/constants.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - All magic numbers moved to constants + - Constants organized by category + - Main file imports constants + - Game behavior unchanged + +**Example:** +```typescript +// constants.ts +export const CANVAS = { + WIDTH: 800, + HEIGHT: 600, +} as const; + +export const PHYSICS = { + GROUND_Y: 450, + SCROLL_SPEED: 2, + JUMP_HEIGHT: 140, + JUMP_DURATION: 1000, +} as const; + +export const GAME_CONFIG = { + DURATION: 300000, // 5 minutes + OBSTACLE_SPAWN_INTERVAL: 5000, + COOL_MODE_DURATION: 2000, + LAMP_SPACING: 500, +} as const; +``` + +### Task 2: Extract Drawing Utilities +- **What**: Move all canvas drawing functions to utilities file +- **Why**: Separates rendering logic from game logic +- **Implementation notes**: + - Extract drawGame function and helper drawing code + - Create functions for: drawBackground, drawCharacter, drawObstacles, drawAlien, drawLamps + - Accept canvas context and necessary state as parameters + - Keep pure functions (no side effects) +- **Files affected**: + - New: `games/crash-course/utils/drawing.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - All drawing code in utilities + - Functions are pure (testable) + - Game renders identically + - Main file 200-300 lines shorter + +**Example:** +```typescript +// utils/drawing.ts +export function drawBackground( + ctx: CanvasRenderingContext2D, + spriteImages: Map, + bgOffsets: number[], +): void { + // Background drawing logic +} + +export function drawCharacter( + ctx: CanvasRenderingContext2D, + spriteImages: Map, + characterState: CharacterState, + frame: number, + x: number, + y: number, + shake: {x: number; y: number}, +): void { + // Character drawing logic +} +``` + +### Task 3: Extract Physics Utilities +- **What**: Move physics calculations to utilities file +- **Why**: Makes physics logic testable and reusable +- **Implementation notes**: + - Extract jump calculation logic + - Extract collision detection logic + - Extract parallax calculation logic + - Create pure functions that return new values +- **Files affected**: + - New: `games/crash-course/utils/physics.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Physics calculations extracted + - Functions are pure and testable + - Game physics unchanged + - Unit tests can be added easily + +**Example:** +```typescript +// utils/physics.ts +export function calculateJumpY( + startTime: number, + currentTime: number, + groundY: number, + jumpHeight: number, + jumpDuration: number, +): {y: number; isComplete: boolean} { + const jumpProgress = currentTime - startTime; + if (jumpProgress >= jumpDuration) { + return {y: groundY, isComplete: true}; + } + + const progress = jumpProgress / jumpDuration; + const height = Math.sin(progress * Math.PI) * jumpHeight; + return {y: groundY - height, isComplete: false}; +} + +export function checkCollision( + obstacleX: number, + obstacleWidth: number, + collisionZoneX: number, + characterX: number, +): boolean { + return obstacleX < collisionZoneX && obstacleX + obstacleWidth > characterX; +} +``` + +### Task 4: Create useSpriteLoader Hook +- **What**: Extract sprite loading logic into a custom hook +- **Why**: Reusable image loading, cleaner separation +- **Implementation notes**: + - Create hook that loads all game images + - Return {images: Map, isLoaded: boolean} + - Handle loading errors gracefully + - Use useEffect for loading +- **Files affected**: + - New: `games/crash-course/hooks/useSpriteLoader.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Hook loads all sprites + - Returns loaded state + - Handles errors + - Main file simplified + +**Example:** +```typescript +// hooks/useSpriteLoader.ts +export function useSpriteLoader(): { + images: Map; + isLoaded: boolean; +} { + const [images, setImages] = useState(new Map()); + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + // Image loading logic + }, []); + + return {images, isLoaded}; +} +``` + +### Task 5: Create useAudioManager Hook +- **What**: Extract audio playback logic into a custom hook +- **Why**: Centralizes audio management, easier to debug +- **Implementation notes**: + - Create hook that manages all 4 audio tracks + - Handle play/pause/stop/volume + - Handle music transitions (tedox → neon owl) + - Accept gameState and isMuted as inputs + - Return toggleMute function +- **Files affected**: + - New: `games/crash-course/hooks/useAudioManager.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Audio plays correctly per game state + - Transitions work smoothly + - Mute/unmute works + - Cleanup on unmount + +**Example:** +```typescript +// hooks/useAudioManager.ts +export function useAudioManager( + gameState: GameState, + isMuted: boolean, +): { + toggleMute: () => void; +} { + const menuAudioRef = useRef(null); + const gameAudioRef = useRef(null); + // ... other refs + + useEffect(() => { + // Setup audio + return () => { + // Cleanup + }; + }, []); + + useEffect(() => { + // Handle audio based on gameState and isMuted + }, [gameState, isMuted]); + + const toggleMute = useCallback(() => { + // Toggle logic + }, []); + + return {toggleMute}; +} +``` + +### Task 6: Create useGameTimer Hook +- **What**: Extract game time tracking into a hook +- **Why**: Separates time management from game loop +- **Implementation notes**: + - Track elapsed time from game start + - Calculate display time (11:55:00 → 00:00:00) + - Check for victory condition (5 minutes elapsed) + - Return {gameTime: string, isComplete: boolean} +- **Files affected**: + - New: `games/crash-course/hooks/useGameTimer.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Timer updates correctly + - Victory detected at 5 minutes + - Time display formatted correctly + - Hook is reusable + +**Example:** +```typescript +// hooks/useGameTimer.ts +export function useGameTimer( + gameStartTime: number, + isPlaying: boolean, +): { + gameTime: string; + isComplete: boolean; +} { + const [gameTime, setGameTime] = useState("11:55:00"); + const [isComplete, setIsComplete] = useState(false); + + useEffect(() => { + if (!isPlaying) return; + + const interval = setInterval(() => { + const elapsed = Date.now() - gameStartTime; + // Calculate and update time + }, 100); + + return () => clearInterval(interval); + }, [gameStartTime, isPlaying]); + + return {gameTime, isComplete}; +} +``` + +### Task 7: Extract Game Loop Core Logic +- **What**: Create a hook for the main game loop +- **Why**: Most complex piece, needs careful extraction +- **Implementation notes**: + - Create useGameLoop hook + - Accept necessary state and refs as parameters + - Handle: obstacle movement, collision detection, spawning, animation updates + - Return updated state values + - Use requestAnimationFrame internally + - Keep drawGame call separate (rendering stays in component) +- **Files affected**: + - New: `games/crash-course/hooks/useGameLoop.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Game loop runs at 60fps + - All game mechanics work + - State updates correctly + - No performance regression + +**Example:** +```typescript +// hooks/useGameLoop.ts +export function useGameLoop( + gameState: GameState, + canvasRef: React.RefObject, + spriteImages: Map, + // ... other parameters +): { + obstacles: Obstacle[]; + walkFrame: number; + characterY: number; + // ... other game state +} { + // Game loop implementation + // Uses useEffect with requestAnimationFrame + // Updates and returns game state +} +``` + +### Task 8: Create Obstacle Management Utilities +- **What**: Extract obstacle-related functions to utilities +- **Why**: Obstacle logic is complex and can be isolated +- **Implementation notes**: + - Keep createObstacle from crash-course-utils + - Add: updateObstaclePositions, checkObstacleCollisions, removeOffscreenObstacles + - Add: findCurrentObstacle + - Pure functions that operate on obstacle arrays +- **Files affected**: + - Modified: `games/crash-course/crash-course-utils.ts` + - New: `games/crash-course/utils/obstacles.ts` (or extend existing utils) +- **Acceptance criteria**: + - Obstacle functions extracted + - Functions are pure and testable + - Game obstacle behavior unchanged + +**Example:** +```typescript +// utils/obstacles.ts +export function updateObstaclePositions( + obstacles: Obstacle[], + scrollSpeed: number, + currentTime: number, +): Obstacle[] { + return obstacles.map((obs) => { + let speed = scrollSpeed; + if (obs.racing && obs.racingStartTime) { + const racingDuration = currentTime - obs.racingStartTime; + const acceleration = Math.min(racingDuration / 500, 5); + speed = scrollSpeed * (1 + acceleration * 2); + } + return {...obs, x: obs.x - speed}; + }); +} + +export function removeOffscreenObstacles( + obstacles: Obstacle[], +): Obstacle[] { + return obstacles.filter((obs) => obs.x + obs.width >= 0); +} +``` + +### Task 9: Create Alien Animation Utilities +- **What**: Extract alien behavior logic to utilities +- **Why**: Alien has complex animation states (floating, blinking, abducting) +- **Implementation notes**: + - Create functions for: calculateAlienPosition, updateAlienBlinkState + - Handle floating motion calculations + - Handle blink timing and state transitions + - Handle abduction and return animations +- **Files affected**: + - New: `games/crash-course/utils/alien.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Alien behavior extracted + - Animations work identically + - Functions are testable + +### Task 10: Create Hooks Directory and Index +- **What**: Organize all hooks with barrel export +- **Why**: Clean imports, clear organization +- **Implementation notes**: + - Create `games/crash-course/hooks/` directory + - Move all hook files into hooks/ + - Create index.ts with re-exports + - Update imports in main file +- **Files affected**: + - New: `games/crash-course/hooks/index.ts` + - All hook files in hooks/ + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - All hooks in hooks/ folder + - Barrel export works + - Imports simplified + +### Task 11: Create Utils Directory and Index +- **What**: Organize all utilities with barrel export +- **Why**: Clean organization, discoverable utilities +- **Implementation notes**: + - Create `games/crash-course/utils/` directory if not exists + - Organize: drawing.ts, physics.ts, obstacles.ts, alien.ts + - Create index.ts with re-exports + - Move crash-course-utils.ts content to appropriate files +- **Files affected**: + - New: `games/crash-course/utils/index.ts` + - All utility files in utils/ + - Modified imports +- **Acceptance criteria**: + - All utilities organized + - Barrel export works + - No duplicate code + +### Task 12: Update Main Component to Use Hooks +- **What**: Replace inline logic with hook calls +- **Why**: Dramatically reduces main file complexity +- **Implementation notes**: + - Replace sprite loading useEffect with useSpriteLoader + - Replace audio useEffects with useAudioManager + - Replace game loop useEffect with useGameLoop + - Replace timer logic with useGameTimer + - Pass hook results to components +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Main file reduced to ~600-800 lines + - All hooks integrated + - Game plays identically + - No functionality lost + +## Technical Details + +### Hook Organization + +``` +games/crash-course/ +├── hooks/ +│ ├── useSpriteLoader.ts (~80 lines) +│ ├── useAudioManager.ts (~150 lines) +│ ├── useGameTimer.ts (~50 lines) +│ ├── useGameLoop.ts (~300-400 lines) +│ └── index.ts (~5 lines) +├── utils/ +│ ├── drawing.ts (~200 lines) +│ ├── physics.ts (~100 lines) +│ ├── obstacles.ts (~150 lines) +│ ├── alien.ts (~100 lines) +│ └── index.ts (~5 lines) +├── constants.ts (~50 lines) +└── crash-course.stories.tsx (~600-800 lines, down from 1200) +``` + +### Main Component After Extraction + +```typescript +// crash-course.stories.tsx (simplified) +const CrashCourseGame = (): React.ReactElement => { + const canvasRef = useRef(null); + const itemRendererRef = useRef(null); + + // Basic state + const [gameState, setGameState] = useState("start"); + const [storyPage, setStoryPage] = useState(1); + const [isMuted, setIsMuted] = useState(false); + + // Custom hooks + const {images: spriteImages, isLoaded} = useSpriteLoader(); + const {toggleMute} = useAudioManager(gameState, isMuted); + const {gameTime, isComplete: timeUp} = useGameTimer(gameStartTime, gameState === "playing"); + const gameLoopState = useGameLoop( + gameState, + canvasRef, + spriteImages, + // ... other params + ); + + // Handle time up + useEffect(() => { + if (timeUp) setGameState("victory"); + }, [timeUp]); + + // Render + return ( + +
+ + + setIsMuted(!isMuted)} + /> + + {gameState === "start" && ( + + )} + + {gameState === "playing" && ( + <> + + {currentObstacle && } + + )} + + {/* Other game states */} +
+
+ ); +}; +``` + +## Testing Considerations + +After this phase: +1. **Functionality Testing**: Game must play identically +2. **Performance Testing**: Verify 60fps maintained +3. **Hook Testing**: Hooks can be tested in isolation +4. **Utility Testing**: Utils can be unit tested +5. **Regression Testing**: Full game playthrough + +## Potential Issues + +### Hook Dependencies +- **Issue**: Complex dependency arrays in useEffect +- **Mitigation**: Use useCallback/useMemo, careful dependency management +- **How to detect**: Infinite re-render loops, stale closures + +### Game Loop Performance +- **Issue**: Hook extraction might impact performance +- **Mitigation**: Profile before/after, use refs for values needed in game loop +- **How to detect**: FPS drops, stuttering animation + +### State Synchronization +- **Issue**: Hooks updating state independently might cause conflicts +- **Mitigation**: Careful state design, consider useReducer for complex state +- **How to detect**: Inconsistent state, race conditions + +### requestAnimationFrame Cleanup +- **Issue**: Animation frame must be canceled properly +- **Mitigation**: Return cleanup function from useEffect +- **How to detect**: Memory leaks, continued animation after unmount + +## Benefits After This Phase + +- Main component reduced from 1200 to ~600-800 lines +- Game logic testable in isolation +- Hooks are reusable +- Utilities can be unit tested +- Clear separation of concerns +- Easier to debug and maintain +- Foundation for Phase 4 optimization diff --git a/PHASE_4_state_management.md b/PHASE_4_state_management.md new file mode 100644 index 00000000000..97c25b0bf2d --- /dev/null +++ b/PHASE_4_state_management.md @@ -0,0 +1,466 @@ +# Phase 4: Refactor State Management + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Simplify the dual state/ref pattern, consolidate related state, and optimize re-renders. + +## Tasks + +### Task 1: Audit Current State Usage +- **What**: Document all state variables and their usage patterns +- **Why**: Understand what can be consolidated before making changes +- **Implementation notes**: + - List all useState variables (currently 30+) + - List all useRef variables + - Identify which state has corresponding refs + - Identify which state is used only in game loop + - Identify which state triggers renders + - Create a spreadsheet or document mapping state usage +- **Files affected**: + - Documentation only (or comments in code) +- **Acceptance criteria**: + - Complete inventory of state/refs + - Clear understanding of duplication + - Identified candidates for consolidation + +### Task 2: Consolidate Jump-Related State +- **What**: Merge jump state and refs into single source of truth +- **Why**: Currently has both isJumping/isJumpingRef, jumpStartTime/jumpStartTimeRef, characterY/characterYRef +- **Implementation notes**: + - Keep refs for values needed in game loop (performance) + - Remove duplicate state variables where possible + - Use refs as source of truth, derive display state if needed + - Or use state and update in batches +- **Files affected**: + - Modified: `games/crash-course/hooks/useGameLoop.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Reduced from 6 jump-related variables to 3-4 + - No duplicate tracking + - Jump behavior unchanged + +**Before:** +```typescript +const [isJumping, setIsJumping] = useState(false); +const [jumpStartTime, setJumpStartTime] = useState(0); +const [characterY, setCharacterY] = useState(GROUND_Y); +const isJumpingRef = useRef(false); +const jumpStartTimeRef = useRef(0); +const characterYRef = useRef(GROUND_Y); +``` + +**After (Option 1 - Ref-only):** +```typescript +const jumpStateRef = useRef({ + isJumping: false, + startTime: 0, + y: GROUND_Y, +}); +// Derive display state when needed for UI +const [characterY, setCharacterY] = useState(GROUND_Y); +``` + +**After (Option 2 - Grouped State):** +```typescript +const [jumpState, setJumpState] = useState({ + isJumping: false, + startTime: 0, + y: GROUND_Y, +}); +// Only ref for game loop access +const jumpStateRef = useRef(jumpState); +useEffect(() => { + jumpStateRef.current = jumpState; +}, [jumpState]); +``` + +### Task 3: Consolidate Alien Animation State +- **What**: Merge alien-related state variables +- **Why**: Currently has 10+ alien-related state/ref pairs +- **Implementation notes**: + - Group alien state: frame, time, blinkState, blinkTimer, isAbducting, isReturning, isFlyingAway, etc. + - Create AlienState type + - Use single state object with ref for game loop + - Reduce variable count significantly +- **Files affected**: + - New type in: `games/crash-course/types.ts` + - Modified: `games/crash-course/hooks/useGameLoop.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Alien state consolidated into 1-2 variables + - Alien animations work identically + - Code is clearer + +**Before:** +```typescript +const [alienFrame, setAlienFrame] = useState(1); +const [alienTime, setAlienTime] = useState(0); +const [alienBlinkState, setAlienBlinkState] = useState("idle"); +const [isAlienAbducting, setIsAlienAbducting] = useState(false); +const [isAlienReturning, setIsAlienReturning] = useState(false); +const [isAlienFlyingAway, setIsAlienFlyingAway] = useState(false); +// ... plus corresponding refs +``` + +**After:** +```typescript +type AlienState = { + frame: number; + time: number; + blinkState: "idle" | "blink1" | "blink2"; + blinkTimer: number; + mode: "floating" | "abducting" | "returning" | "flyingAway"; + returnStartTime?: number; + returnStartPos?: {x: number; y: number}; + flyAwayStartTime?: number; +}; + +const [alienState, setAlienState] = useState({ + frame: 1, + time: 0, + blinkState: "idle", + blinkTimer: 2000, + mode: "floating", +}); +const alienStateRef = useRef(alienState); +``` + +### Task 4: Consolidate Visual Effect State +- **What**: Group shake, parallax, and other visual effects +- **Why**: These are related rendering concerns +- **Implementation notes**: + - Group: shakeOffset, isShaking, parallaxOffsets + - Create VisualEffectsState type + - Single state object for all visual effects + - Simplifies render logic +- **Files affected**: + - New type in: `games/crash-course/types.ts` + - Modified: `games/crash-course/hooks/useGameLoop.ts` + - Modified: `games/crash-course/utils/drawing.ts` +- **Acceptance criteria**: + - Visual effects grouped + - Rendering unchanged + - Clearer code organization + +**After:** +```typescript +type VisualEffectsState = { + shake: {x: number; y: number}; + isShaking: boolean; + parallaxOffsets: number[]; +}; + +const [visualEffects, setVisualEffects] = useState({ + shake: {x: 0, y: 0}, + isShaking: false, + parallaxOffsets: [0, 0, 0, 0, 0], +}); +``` + +### Task 5: Use useReducer for Game State +- **What**: Convert complex game state to useReducer pattern +- **Why**: Complex state transitions are easier to manage with reducer +- **Implementation notes**: + - Create game reducer with actions: START_GAME, SPAWN_OBSTACLE, ANSWER_CORRECT, ANSWER_INCORRECT, etc. + - Move state update logic into reducer + - Centralize state transitions + - Makes state changes predictable and testable +- **Files affected**: + - New: `games/crash-course/hooks/useGameReducer.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Complex state managed by reducer + - State transitions clearer + - Game behavior unchanged + - Easier to debug state changes + +**Example:** +```typescript +// hooks/useGameReducer.ts +type GameAction = + | {type: "START_GAME"} + | {type: "NEXT_STORY_PAGE"} + | {type: "SPAWN_OBSTACLE"; obstacle: Obstacle} + | {type: "ANSWER_CORRECT"; obstacleId: string; points: number} + | {type: "ANSWER_INCORRECT"} + | {type: "LOSE_LIFE"} + | {type: "GAME_OVER"} + | {type: "VICTORY"}; + +type GameReducerState = { + gameState: GameState; + storyPage: number; + score: number; + lives: number; + obstacles: Obstacle[]; + currentObstacle: Obstacle | null; +}; + +function gameReducer( + state: GameReducerState, + action: GameAction, +): GameReducerState { + switch (action.type) { + case "START_GAME": + return { + ...state, + gameState: "story", + storyPage: 1, + score: 0, + lives: 3, + obstacles: [], + }; + case "ANSWER_CORRECT": + return { + ...state, + score: state.score + action.points, + obstacles: state.obstacles.map((obs) => + obs.id === action.obstacleId + ? {...obs, answered: true, correct: true} + : obs, + ), + }; + // ... other cases + default: + return state; + } +} + +export function useGameReducer() { + return useReducer(gameReducer, initialState); +} +``` + +### Task 6: Optimize Re-renders with useMemo/useCallback +- **What**: Memoize expensive calculations and callback functions +- **Why**: Prevent unnecessary re-renders, improve performance +- **Implementation notes**: + - Identify expensive calculations (e.g., obstacle distance calculations) + - Wrap in useMemo with proper dependencies + - Wrap callbacks in useCallback to prevent re-creation + - Profile before/after to verify improvement +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` + - Modified: `games/crash-course/hooks/useGameLoop.ts` +- **Acceptance criteria**: + - Key calculations memoized + - Callbacks stable across renders + - Measurable performance improvement + - No stale closure bugs + +**Example:** +```typescript +// Memoize obstacle distance calculation +const currentObstacleDistance = useMemo(() => { + if (!currentObstacle) return Infinity; + const liveObstacle = obstacles.find((o) => o.id === currentObstacle.id); + return liveObstacle ? liveObstacle.x - COLLISION_ZONE_X : Infinity; +}, [currentObstacle, obstacles]); + +// Stable callback +const handleCheckAnswer = useCallback(() => { + if (!currentObstacle) return; + // ... answer checking logic +}, [currentObstacle, /* other dependencies */]); +``` + +### Task 7: Extract Separate State Hooks +- **What**: Create custom hooks for independent state domains +- **Why**: Further separation of concerns +- **Implementation notes**: + - Create useCharacterState hook (position, state, animation) + - Create useObstacleManager hook (spawning, movement, collision) + - Create useAlienBehavior hook (animation, abduction, return) + - Each hook manages its own domain +- **Files affected**: + - New: `games/crash-course/hooks/useCharacterState.ts` + - New: `games/crash-course/hooks/useObstacleManager.ts` + - New: `games/crash-course/hooks/useAlienBehavior.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - State organized by domain + - Hooks are focused and testable + - Main component cleaner + - Game works identically + +### Task 8: Remove Unnecessary State Synchronization +- **What**: Eliminate redundant state updates and synchronization +- **Why**: Reduces complexity and potential bugs +- **Implementation notes**: + - Identify where same value is stored in both state and ref + - Choose one source of truth (usually ref for game loop, state for rendering) + - Remove synchronization code + - Use derived values where possible +- **Files affected**: + - Modified: `games/crash-course/hooks/useGameLoop.ts` + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - No redundant updates + - Simpler code + - Game behavior unchanged + +### Task 9: Implement Batch State Updates +- **What**: Batch multiple setState calls into single update +- **Why**: Reduces re-renders, improves performance +- **Implementation notes**: + - Use React 18 automatic batching + - Group related setState calls + - Use setState callback form for dependent updates + - Consider using unstable_batchedUpdates if needed (older React) +- **Files affected**: + - Modified: `games/crash-course/hooks/useGameLoop.ts` +- **Acceptance criteria**: + - Multiple updates batched + - Fewer re-renders + - Better performance + +**Example:** +```typescript +// Before: Multiple separate updates +setScore(score + 10); +setCharacterState("coolMode"); +setCoolModeEndTime(Date.now() + 2000); +setAnswerFeedback({show: true, correct: true, message: "Correct!"}); + +// After: Batched (React 18 does this automatically) +// Or use a single state update with reducer +dispatch({ + type: "ANSWER_CORRECT", + points: 10, + feedback: "Correct!", +}); +``` + +### Task 10: Add State Performance Monitoring +- **What**: Add dev-mode logging to track state updates +- **Why**: Helps identify performance issues during development +- **Implementation notes**: + - Use React DevTools Profiler + - Add useEffect with logging in dev mode + - Track render counts for expensive components + - Add performance marks for game loop +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` + - Modified: `games/crash-course/hooks/useGameLoop.ts` +- **Acceptance criteria**: + - Can identify excessive re-renders + - Performance data available in dev mode + - No performance impact in production + +**Example:** +```typescript +if (process.env.NODE_ENV === "development") { + useEffect(() => { + console.log("Component rendered", { + gameState, + obstacles: obstacles.length, + score, + }); + }); +} +``` + +## Technical Details + +### State Organization After Phase 4 + +```typescript +// Main component state structure +const CrashCourseGame = () => { + // Core game state (managed by reducer) + const [gameState, dispatch] = useGameReducer(); + + // UI state + const [storyPage, setStoryPage] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [benevolenceMessage, setBenevolenceMessage] = useState(false); + + // Domain-specific hooks + const characterState = useCharacterState(gameState); + const obstacles = useObstacleManager(gameState); + const alienState = useAlienBehavior(gameState, characterState); + + // Visual effects (grouped) + const [visualEffects, setVisualEffects] = useState({ + shake: {x: 0, y: 0}, + isShaking: false, + parallaxOffsets: [0, 0, 0, 0, 0], + }); + + // Infrastructure hooks + const {images, isLoaded} = useSpriteLoader(); + const {toggleMute} = useAudioManager(gameState.gameState, isMuted); + const {gameTime, isComplete} = useGameTimer(gameStartTime, gameState.gameState === "playing"); + + // Game loop (orchestrates everything) + useGameLoop({ + gameState, + characterState, + obstacles, + alienState, + visualEffects, + dispatch, + }); + + // Render... +}; +``` + +### Before vs After Comparison + +| Metric | Before Phase 4 | After Phase 4 | +|--------|----------------|---------------| +| State variables | 30+ | 10-15 | +| Ref variables | 20+ | 5-10 | +| Duplicate state/ref | 15+ pairs | 0-3 pairs | +| Main component lines | 600-800 | 400-500 | +| State updates per frame | 10-15 | 3-5 (batched) | +| Re-renders per frame | 5-10 | 1-2 | + +## Testing Considerations + +After this phase: +1. **Performance Testing**: + - Measure FPS before/after + - Check render counts with React DevTools Profiler + - Verify no performance regression +2. **Functionality Testing**: + - Full playthrough + - All game mechanics work + - State transitions correct +3. **State Debugging**: + - Use React DevTools to inspect state + - Verify state structure is clearer + - Check for memory leaks + +## Potential Issues + +### Over-optimization +- **Issue**: Premature optimization can make code harder to read +- **Mitigation**: Only optimize where there's measurable benefit +- **How to detect**: Code is complex but performance unchanged + +### Stale Closures +- **Issue**: useMemo/useCallback can capture stale values +- **Mitigation**: Careful dependency management, ESLint rules +- **How to detect**: Unexpected behavior, old values being used + +### useReducer Complexity +- **Issue**: Reducer can become large and complex +- **Mitigation**: Split into multiple reducers or use context +- **How to detect**: Reducer file > 300 lines + +### State Synchronization Bugs +- **Issue**: Removing synchronization might break things +- **Mitigation**: Thorough testing, careful refactoring +- **How to detect**: State inconsistencies, race conditions + +## Benefits After This Phase + +- Cleaner, more maintainable state management +- Better performance (fewer re-renders) +- Easier to debug (centralized state logic) +- More testable (reducers are pure functions) +- Foundation for future enhancements +- Clear separation of state concerns +- Reduced cognitive load diff --git a/PHASE_5_documentation.md b/PHASE_5_documentation.md new file mode 100644 index 00000000000..be48d76a7f7 --- /dev/null +++ b/PHASE_5_documentation.md @@ -0,0 +1,578 @@ +# Phase 5: Documentation and Polish + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Add comprehensive documentation, finalize naming, verify everything works, and clean up. + +## Tasks + +### Task 1: Create Main README +- **What**: Write comprehensive README for the crash-course game +- **Why**: Documents purpose, architecture, how to run, and how to extend +- **Implementation notes**: + - Create README.md in games/crash-course/ + - Include: overview, features, how to play, architecture, development guide + - Add ASCII architecture diagram + - Link to Perseus documentation + - Document known issues/limitations +- **Files affected**: + - New: `games/crash-course/README.md` +- **Acceptance criteria**: + - README covers all key aspects + - Clear instructions for developers + - Architecture diagram included + - Well-formatted and readable + +**README Outline:** +```markdown +# Crash Course + +An educational endless runner game demonstrating Perseus widget integration. + +## Overview +[What it is, purpose, proof of concept status] + +## Features +[List of game features] + +## How to Play +[User instructions] + +## How to Run +[Developer instructions] + +## Architecture +[Component breakdown, data flow, hooks] +[ASCII diagram] + +## Project Structure +[Directory tree with explanations] + +## Key Technologies +[React, Perseus, Canvas, etc.] + +## Development Guide +[How to modify, extend, debug] + +## Testing +[How to test] + +## Known Issues +[Current limitations] + +## Future Enhancements +[Ideas for improvement] + +## Credits +[Asset sources, music credits] +``` + +### Task 2: Add JSDoc Comments to Functions +- **What**: Add JSDoc documentation to all exported functions and hooks +- **Why**: Improves discoverability and IDE support +- **Implementation notes**: + - Document all exported functions in utils/ + - Document all custom hooks + - Include: description, parameters, return values, examples + - Use TypeScript types in JSDoc +- **Files affected**: + - Modified: All files in `games/crash-course/utils/` + - Modified: All files in `games/crash-course/hooks/` + - Modified: `games/crash-course/components/` files +- **Acceptance criteria**: + - All public APIs documented + - JSDoc shows in IDE tooltips + - Examples included where helpful + +**Example:** +```typescript +/** + * Calculates the character's Y position during a jump using a parabolic arc. + * + * The jump follows a sine wave pattern for smooth, natural-looking motion. + * + * @param startTime - When the jump started (timestamp) + * @param currentTime - Current time (timestamp) + * @param groundY - Y coordinate of the ground + * @param jumpHeight - Maximum height of the jump in pixels + * @param jumpDuration - Total duration of the jump in milliseconds + * @returns Object containing the current Y position and whether jump is complete + * + * @example + * ```typescript + * const {y, isComplete} = calculateJumpY( + * Date.now() - 500, // Started 500ms ago + * Date.now(), + * 450, // Ground at y=450 + * 140, // Jump 140 pixels high + * 1000 // 1 second total + * ); + * ``` + */ +export function calculateJumpY( + startTime: number, + currentTime: number, + groundY: number, + jumpHeight: number, + jumpDuration: number, +): {y: number; isComplete: boolean} { + // Implementation +} +``` + +### Task 3: Add Inline Code Comments +- **What**: Add explanatory comments for complex logic +- **Why**: Makes code easier to understand and maintain +- **Implementation notes**: + - Add comments for non-obvious logic + - Explain "why" not "what" (code shows what) + - Document complex algorithms (jump physics, collision detection) + - Explain magic numbers if any remain + - Add TODO comments for known issues +- **Files affected**: + - Modified: All TypeScript files with complex logic +- **Acceptance criteria**: + - Complex sections have explanatory comments + - Code intent is clear + - Future maintainers can understand logic + +**Example:** +```typescript +// Calculate parabolic jump arc using sine wave for smooth motion. +// Progress goes from 0 to 1 over JUMP_DURATION, sine gives us +// smooth acceleration at start/end of jump. +const progress = jumpProgress / JUMP_DURATION; +const jumpHeight = Math.sin(progress * Math.PI) * JUMP_HEIGHT; +const newY = GROUND_Y - jumpHeight; +``` + +### Task 4: Document Component Props +- **What**: Add comments/documentation for complex component props +- **Why**: Makes components easier to use +- **Implementation notes**: + - Add JSDoc to component prop types + - Document callback signatures + - Explain non-obvious prop requirements + - Add usage examples in comments +- **Files affected**: + - Modified: All files in `games/crash-course/components/` +- **Acceptance criteria**: + - All props documented + - Callback signatures clear + - Components easy to use + +**Example:** +```typescript +/** + * Props for the QuestionOverlay component. + */ +type QuestionOverlayProps = { + /** The current obstacle/question to display */ + obstacle: Obstacle; + + /** + * Remaining distance in pixels before collision. + * Used to calculate timer bar progress. + */ + remainingDistance: number; + + /** + * Callback fired when user clicks "Check Answer". + * Should validate the answer and update game state. + */ + onCheckAnswer: () => void; + + /** + * Feedback to display after answer submission. + * If show is false, no feedback is displayed. + */ + answerFeedback: { + show: boolean; + correct: boolean; + message: string; + }; +}; +``` + +### Task 5: Update Storybook Story Documentation +- **What**: Enhance the Storybook story with controls and documentation +- **Why**: Makes the demo more interactive and informative +- **Implementation notes**: + - Update story metadata with detailed description + - Add Storybook controls for configurable parameters (if applicable) + - Add links to source code + - Document how to use in Storybook +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story has detailed description + - Controls work (if applicable) + - Easy to explore in Storybook + +**Example:** +```typescript +const meta: Meta = { + title: "Games/Crash Course", + component: CrashCourseGame, + parameters: { + docs: { + description: { + component: ` +# Crash Course + +An endless runner game showcasing Perseus widget integration in an educational gaming context. + +## Features +- **Educational Content**: Integrates real Perseus math questions +- **Progressive Difficulty**: Questions spawn every 5 seconds +- **Time-Based Challenge**: Survive for 5 minutes to win +- **Multiple Question Types**: Addition, subtraction, multiplication, division +- **Dynamic Animations**: Parallax backgrounds, alien behaviors, character states +- **Story Mode**: 7-page narrative introduction + +## Technical Highlights +- Canvas-based rendering at 60fps +- Custom React hooks for game logic +- Perseus ServerItemRenderer integration +- Audio management with multiple tracks +- Complex state management + +## How to Play +1. Click "Start" on the title screen +2. Watch the story sequence +3. Answer math questions to jump over obstacles +4. Survive until midnight (5 minutes) to win + +## Architecture +See /games/crash-course/README.md for detailed architecture documentation. + `, + }, + }, + layout: "fullscreen", + }, +}; +``` + +### Task 6: Add Architecture Diagram +- **What**: Create ASCII art or diagram showing component relationships +- **Why**: Visual aid for understanding the system +- **Implementation notes**: + - Create clear ASCII diagram + - Show data flow + - Show component hierarchy + - Include in README and code comments +- **Files affected**: + - Modified: `games/crash-course/README.md` +- **Acceptance criteria**: + - Diagram is clear and accurate + - Shows key relationships + - Easy to understand + +**Example:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ CrashCourseGame │ +│ (Main Orchestrator) │ +└─────────────┬───────────────────────────────────────────────┘ + │ + ┌─────────┼─────────┬──────────┬──────────┬──────────┐ + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ +┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ +│Sprite │ │Audio │ │Game │ │Game │ │Character│ │Obstacle│ +│Loader │ │Manager │ │Timer │ │Loop │ │State │ │Manager │ +└────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ + │Drawing │ │Physics │ │Collision│ + │Utils │ │Utils │ │Detection│ + └────────┘ └────────┘ └────────┘ + +Game State Flow: +START → STORY (7 pages) → PLAYING → [VICTORY | CAR_BONUS → GAMEOVER] +``` + +### Task 7: Document Known Issues and Limitations +- **What**: Create a list of current limitations and technical debt +- **Why**: Transparency about what needs improvement +- **Implementation notes**: + - List performance limitations + - Document browser compatibility issues + - Note areas that could be improved + - Explain proof-of-concept limitations +- **Files affected**: + - Modified: `games/crash-course/README.md` + - New: `games/crash-course/TECHNICAL_DEBT.md` (optional) +- **Acceptance criteria**: + - Known issues documented + - Workarounds noted + - Future work identified + +**Example:** +```markdown +## Known Issues + +### Performance +- Game loop may stutter on older devices (requires 60fps) +- Large number of obstacles can impact performance +- No frame-skipping or adaptive quality + +### Browser Compatibility +- Requires HTML5 Canvas support +- Audio autoplay blocked until user interaction +- Not tested in all browsers + +### Accessibility +- No keyboard-only navigation +- Screen reader support limited +- No visual settings (contrast, motion reduction) + +### Mobile +- Not optimized for touch controls +- On-screen keyboard may cover game area +- Performance varies significantly + +### Technical Debt +- Some duplicate state/ref patterns remain (performance trade-offs) +- Game loop could be further optimized +- Asset loading could use better error handling +- No unit tests yet +``` + +### Task 8: Add Development Guide +- **What**: Document how to modify and extend the game +- **Why**: Helps future developers work on the codebase +- **Implementation notes**: + - Explain how to add new question types + - Document how to modify game parameters + - Show how to add new screens/states + - Explain testing approach +- **Files affected**: + - Modified: `games/crash-course/README.md` +- **Acceptance criteria**: + - Common modifications documented + - Extension points identified + - Examples provided + +**Example:** +```markdown +## Development Guide + +### Adding a New Question Type + +1. Add generator function in `crash-course-utils.ts`: +```typescript +function generateNewTypeQuestion(): PerseusItem { + // Create Perseus item +} +``` + +2. Update `generateQuestion()` to include new type: +```typescript +const types = ["addition", "subtraction", "multiplication", "division", "newType"]; +``` + +### Modifying Game Parameters + +All game parameters are in `constants.ts`. Common modifications: + +- **Game Speed**: Adjust `PHYSICS.SCROLL_SPEED` +- **Difficulty**: Adjust `GAME_CONFIG.OBSTACLE_SPAWN_INTERVAL` +- **Game Length**: Adjust `GAME_CONFIG.DURATION` + +### Adding a New Screen + +1. Create component in `components/NewScreen.tsx` +2. Add state to GameState type: `type GameState = ... | "newScreen"` +3. Add rendering in main component: +```typescript +{gameState === "newScreen" && } +``` +``` + +### Task 9: Verify All Naming is Consistent +- **What**: Final pass to ensure no "math-blaster" references remain +- **Why**: Professional polish, consistency +- **Implementation notes**: + - Search codebase for "math-blaster", "mathblaster", "MathBlaster" + - Update any remaining references to "crash-course", "CrashCourse" + - Check comments, variable names, class names + - Update any console.log statements +- **Files affected**: + - Any files with remaining old naming +- **Acceptance criteria**: + - No "math-blaster" references + - Consistent "crash-course" naming throughout + - Professional appearance + +### Task 10: Final Testing and Cleanup +- **What**: Comprehensive testing and code cleanup +- **Why**: Ensure everything works before completion +- **Implementation notes**: + - Full playthrough of the game + - Test all screens and transitions + - Verify assets load correctly + - Check audio playback + - Run linting: `pnpm lint` + - Run type checking: `pnpm tsc` + - Test in Storybook + - Remove any commented-out code + - Remove debug/console.log statements + - Remove unused imports +- **Files affected**: + - All files in `games/crash-course/` +- **Acceptance criteria**: + - Game works perfectly + - No console errors + - Lint passes + - Type check passes + - No debug code remains + - Code is clean and professional + +### Task 11: Create Migration Guide +- **What**: Document the changes made during refactoring +- **Why**: Helps team understand what changed +- **Implementation notes**: + - Summarize major changes + - Document new file structure + - Note breaking changes (if any) + - Provide before/after comparisons +- **Files affected**: + - New: `games/crash-course/MIGRATION.md` +- **Acceptance criteria**: + - Changes clearly documented + - Team can understand refactoring + - Before/after comparisons included + +**Example:** +```markdown +# Migration Guide + +This document summarizes the refactoring from math-blaster to crash-course. + +## What Changed + +### File Organization +- **Before**: All files in `packages/perseus/src/__docs__/` +- **After**: Organized in `packages/perseus/src/games/crash-course/` + +### Component Structure +- **Before**: 1 monolithic 1673-line file +- **After**: 20+ focused files with clear responsibilities + +### Asset Organization +- **Before**: 40+ assets scattered in `__docs__/` +- **After**: Assets organized in subdirectories by type + +### State Management +- **Before**: 30+ state variables, 20+ refs, heavy duplication +- **After**: Consolidated state with hooks and reducer pattern + +## File Mapping + +| Old Location | New Location | +|--------------|--------------| +| `__docs__/math-blaster-game.stories.tsx` | `games/crash-course/crash-course.stories.tsx` | +| `__docs__/math-blaster-utils.ts` | `games/crash-course/crash-course-utils.ts` | +| ... | ... | + +## Breaking Changes + +None - this is an internal refactoring. The Storybook story moved from "Math Blaster Game" to "Games/Crash Course". +``` + +### Task 12: Optional - Clean Up Original Files +- **What**: Remove old files from __docs__ once verified +- **Why**: Prevent confusion, clean up codebase +- **Implementation notes**: + - **ONLY DO THIS AFTER PHASE 5 IS COMPLETE AND TESTED** + - Delete math-blaster files from __docs__ + - Delete asset files that were moved + - Update any references + - Commit as separate change +- **Files affected**: + - Deleted: All math-blaster files in `__docs__/` + - Deleted: All moved asset files +- **Acceptance criteria**: + - Old files removed + - No broken references + - Game still works + - Git history preserved (if using git mv) + +## Technical Details + +### Documentation Standards + +Follow these guidelines: +- Use Markdown for all documentation +- Keep paragraphs short (3-4 sentences max) +- Use code examples liberally +- Include visual diagrams where helpful +- Link between documents +- Use proper heading hierarchy +- Include table of contents for long docs + +### JSDoc Standards + +```typescript +/** + * Brief one-line description. + * + * Longer description if needed. Can span multiple lines. + * Explain the "why" and important context. + * + * @param paramName - Description of parameter + * @param anotherParam - Another description + * @returns Description of return value + * @throws {ErrorType} When this error occurs + * + * @example + * ```typescript + * // Usage example + * const result = functionName(arg1, arg2); + * ``` + * + * @see {@link RelatedFunction} for related functionality + */ +``` + +## Testing Considerations + +After this phase: +1. **Documentation Review**: Have someone else read the docs +2. **Final Playthrough**: Complete game session +3. **Storybook Check**: Verify story displays correctly +4. **Build Verification**: `pnpm build` succeeds +5. **Link Checking**: All documentation links work + +## Benefits After This Phase + +- Comprehensive documentation for future developers +- Clear understanding of system architecture +- Easy to onboard new contributors +- Professional, polished codebase +- Clear path for future enhancements +- Well-documented APIs and components +- Project is "complete" and ready for use/extension + +## Project Completion Checklist + +- [ ] Main README created and comprehensive +- [ ] All public APIs have JSDoc documentation +- [ ] Complex logic has inline comments +- [ ] Component props documented +- [ ] Storybook story enhanced +- [ ] Architecture diagram included +- [ ] Known issues documented +- [ ] Development guide written +- [ ] All naming consistent (no "math-blaster") +- [ ] Full testing completed successfully +- [ ] Migration guide created +- [ ] Original files cleaned up (optional) +- [ ] Lint passes +- [ ] Type check passes +- [ ] Game works perfectly in Storybook +- [ ] Team review completed +- [ ] Ready for production use diff --git a/packages/perseus/src/__docs__/car-bonus-scene.module.css b/packages/perseus/src/__docs__/car-bonus-scene.module.css new file mode 100644 index 00000000000..48ffbb10e2d --- /dev/null +++ b/packages/perseus/src/__docs__/car-bonus-scene.module.css @@ -0,0 +1,142 @@ +@import url('https://fonts.googleapis.com/css2?family=Quantico:wght@400;700&display=swap'); + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background: #000; +} + +.backgroundImage { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.text { + position: absolute; + top: 20%; + left: 50%; + transform: translateX(-50%); + font-family: 'Quantico', sans-serif; + font-size: 48px; + font-weight: bold; + color: #ffffff; + text-shadow: 3px 3px 0px #000000; + letter-spacing: 2px; + z-index: 10; +} + +.cursor { + animation: blink 0.5s infinite; +} + +.carContainer { + position: absolute; + bottom: 22px; /* Ground level: 600px canvas - 578px ground = 22px (GROUND_Y 450 + CHARACTER_HEIGHT 128) */ + left: 50%; +} + +.carContainer.sliding { + animation: slideInObstacle 0.8s ease-out forwards; +} + +.carContainer.stopped { + left: 50%; + transform: translateX(-50%); +} + +.carImage { + width: 256px; + height: 256px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; +} + +@keyframes slideInObstacle { + from { + left: 100%; + transform: translateX(0); + } + to { + left: 50%; + transform: translateX(-50%); + } +} + +@keyframes blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} + +.bonusLevelText { + position: absolute; + top: 30%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Quantico', sans-serif; + font-size: 72px; + font-weight: bold; + color: #ffff00; + text-shadow: 4px 4px 0px #000000, 0 0 20px #ffff00; + letter-spacing: 4px; + z-index: 20; + white-space: nowrap; +} + +.nevermindText { + position: absolute; + top: 30%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Quantico', sans-serif; + font-size: 72px; + font-weight: bold; + color: #ffff00; + text-shadow: 4px 4px 0px #000000, 0 0 20px #ffff00; + letter-spacing: 4px; + z-index: 20; + white-space: nowrap; +} + +.nextButton { + position: absolute; + bottom: 20px; + right: 20px; + cursor: pointer; + pointer-events: all; + transition: transform 0.2s ease; + z-index: 30; + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + animation: buttonShine 2s ease-in-out infinite; +} + +.nextButton:hover { + transform: scale(1.05); + filter: drop-shadow(0 0 20px rgba(255, 0, 255, 0.8)); +} + +@keyframes buttonShine { + 0%, 100% { + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + } + 50% { + filter: drop-shadow(0 0 20px rgba(0, 255, 255, 0.8)); + } +} diff --git a/packages/perseus/src/__docs__/car-bonus-scene.stories.tsx b/packages/perseus/src/__docs__/car-bonus-scene.stories.tsx new file mode 100644 index 00000000000..0cfed786522 --- /dev/null +++ b/packages/perseus/src/__docs__/car-bonus-scene.stories.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +import {CarBonusScene} from "./car-bonus-scene"; + +import type {Meta, StoryObj} from "@storybook/react-vite"; + +const meta: Meta = { + title: "Games/Car Bonus Scene", + component: CarBonusScene, + parameters: { + docs: { + description: { + component: + "A comedic bonus scene that plays when the player runs out of lives in Math Blaster. " + + "The car self-destructs in a humorous reference to the Street Fighter 2 bonus level.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onComplete: () => { + // Car bonus scene completed + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/packages/perseus/src/__docs__/car-bonus-scene.tsx b/packages/perseus/src/__docs__/car-bonus-scene.tsx new file mode 100644 index 00000000000..5f13520ee2a --- /dev/null +++ b/packages/perseus/src/__docs__/car-bonus-scene.tsx @@ -0,0 +1,202 @@ +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; + +import bonusGameAudio from "../games/crash-course/assets/audio/bonusgame.wav"; +import bonus1Img from "../games/crash-course/assets/ui/bonus1.png"; +import bonus2Img from "../games/crash-course/assets/ui/bonus2.png"; +import styles from "./car-bonus-scene.module.css"; +import explosionAudio from "../games/crash-course/assets/audio/explosion.wav"; +import nextImg from "../games/crash-course/assets/ui/next.png"; +import skidImg from "../games/crash-course/assets/ui/skid.png"; +import tireSquealAudio from "../games/crash-course/assets/audio/tires_squal_loop.wav"; + +type BonusState = "skid" | "bonusLevel" | "explosion" | "complete"; + +type CarBonusSceneProps = { + onComplete: () => void; +}; + +export const CarBonusScene = ({onComplete}: CarBonusSceneProps) => { + const [bonusState, setBonusState] = useState("skid"); + const [displayText, setDisplayText] = useState(""); + const [flashVisible, setFlashVisible] = useState(true); + const [nevermindFlashVisible, setNevermindFlashVisible] = useState(true); + const [showSkidImage, setShowSkidImage] = useState(false); + + const tireSquealRef = useRef(null); + const bonusGameRef = useRef(null); + const explosionRef = useRef(null); + const nevermindIntervalRef = useRef | null>(null); + + useEffect(() => { + // Initialize audio + const tireSqueal = new Audio(tireSquealAudio); + tireSqueal.loop = false; + tireSqueal.volume = 0.7; + tireSquealRef.current = tireSqueal; + + const bonusGame = new Audio(bonusGameAudio); + bonusGame.loop = false; + bonusGame.volume = 0.7; + bonusGameRef.current = bonusGame; + + const explosion = new Audio(explosionAudio); + explosion.loop = false; + explosion.volume = 0.7; + explosionRef.current = explosion; + + // Sequence timing + const timers: ReturnType[] = []; + + // Phase 1a: Black screen + tire squeal (play only 1.5 seconds) + tireSqueal.play().catch((error) => { + console.log("Tire squeal audio play failed:", error); + }); + + // Stop tire squeal after 1.5 seconds + timers.push( + setTimeout(() => { + if (tireSquealRef.current) { + tireSquealRef.current.pause(); + tireSquealRef.current.currentTime = 0; + } + }, 1500), + ); + + // Phase 1b: Show skid image after black screen + timers.push( + setTimeout(() => { + setShowSkidImage(true); + setDisplayText("Oh. I guess it stopped."); + }, 1500), + ); + + // Note: Phase 2 (Bonus Level) is now triggered by user clicking next button on skid screen + // No automatic transition from skid to bonus level + + // Cleanup + return () => { + timers.forEach((timer) => clearTimeout(timer)); + if (nevermindIntervalRef.current) { + clearInterval(nevermindIntervalRef.current); + } + if (tireSquealRef.current) { + tireSquealRef.current.pause(); + tireSquealRef.current.currentTime = 0; + } + if (bonusGameRef.current) { + bonusGameRef.current.pause(); + bonusGameRef.current.currentTime = 0; + } + if (explosionRef.current) { + explosionRef.current.pause(); + explosionRef.current.currentTime = 0; + } + }; + }, [onComplete]); + + // Determine which image to show based on state + let backgroundImage: string | null = null; + if (bonusState === "skid" && showSkidImage) { + backgroundImage = skidImg; + } else if (bonusState === "bonusLevel") { + backgroundImage = bonus1Img; + } else if (bonusState === "explosion") { + backgroundImage = bonus2Img; + } + + const handleSkidNext = () => { + // Transition from skid to bonus level + setBonusState("bonusLevel"); + setDisplayText(""); // Clear previous text + + // Start bonus level music + if (bonusGameRef.current) { + bonusGameRef.current.play().catch((error) => { + console.log("Bonus game audio play failed:", error); + }); + } + + // Flash "BONUS LEVEL" continuously for the entire duration + const flashInterval = setInterval(() => { + setFlashVisible((prev) => !prev); + }, 500); // 500ms on, 500ms off + + // After 7 seconds, transition to explosion + setTimeout(() => { + clearInterval(flashInterval); + + // Abruptly stop bonus music + if (bonusGameRef.current) { + bonusGameRef.current.pause(); + bonusGameRef.current.currentTime = 0; + } + + // Play explosion + if (explosionRef.current) { + explosionRef.current.play().catch((error) => { + console.log("Explosion audio play failed:", error); + }); + } + + setBonusState("explosion"); + setFlashVisible(false); + + // Start flashing "NEVERMIND" text + nevermindIntervalRef.current = setInterval(() => { + setNevermindFlashVisible((prev) => !prev); + }, 500); + }, 7000); // 7 seconds for bonus level + }; + + const handleExplosionNext = () => { + // Clear the nevermind flashing interval + if (nevermindIntervalRef.current) { + clearInterval(nevermindIntervalRef.current); + } + setBonusState("complete"); + onComplete(); + }; + + return ( +
+ {backgroundImage && ( + Bonus scene + )} + + {displayText && ( +
{displayText}
+ )} + + {bonusState === "bonusLevel" && flashVisible && ( +
BONUS LEVEL
+ )} + + {bonusState === "explosion" && nevermindFlashVisible && ( +
NEVERMIND
+ )} + + {bonusState === "skid" && showSkidImage && ( + Next + )} + + {bonusState === "explosion" && ( + Next + )} +
+ ); +}; diff --git a/packages/perseus/src/__docs__/idle.png b/packages/perseus/src/__docs__/idle.png new file mode 100644 index 00000000000..8334bba1bb7 Binary files /dev/null and b/packages/perseus/src/__docs__/idle.png differ diff --git a/packages/perseus/src/__docs__/math-blaster-game.module.css b/packages/perseus/src/__docs__/math-blaster-game.module.css new file mode 100644 index 00000000000..b8bd66837fe --- /dev/null +++ b/packages/perseus/src/__docs__/math-blaster-game.module.css @@ -0,0 +1,456 @@ +@import url('https://fonts.googleapis.com/css2?family=Quantico:wght@400;700&display=swap'); + +.gameContainer { + position: relative; + width: 800px; + height: 600px; + margin: 20px auto; + border: 2px solid #333; + background: #87ceeb; + overflow: hidden; + font-family: 'Quantico', sans-serif; +} + +.gameCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.muteButton { + position: absolute; + top: 12px; + right: 10px; + width: 40px; + height: 40px; + cursor: pointer; + z-index: 100; + pointer-events: all; + transition: transform 0.2s ease; +} + +.muteButton:hover { + transform: scale(1.1); +} + +.hudBackground { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 64px; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.7) 0%, + rgba(0, 0, 0, 0.4) 70%, + rgba(0, 0, 0, 0) 100% + ); + z-index: 4; + pointer-events: none; +} + +.questionOverlay { + position: absolute; + background: rgba(0, 0, 0, 0.92); + border: 3px solid #00ffff; + border-radius: 8px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 255, 255, 0.4); + max-width: 400px; + z-index: 10; + color: #fff; + pointer-events: all; +} + +.questionOverlay :global(.perseus-widget-container), +.questionOverlay :global(p), +.questionOverlay :global(div), +.questionOverlay :global(label), +.questionOverlay :global(span), +.questionOverlay :global(.katex), +.questionOverlay :global(.katex *) { + color: #fff !important; +} + +.hud { + position: absolute; + top: 10px; + left: 10px; + right: 60px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 5; + pointer-events: none; +} + +.score { + background: rgba(0, 0, 0, 0.85); + padding: 10px 16px; + border-radius: 4px; + font-size: 18px; + font-weight: bold; + color: #00ffff; + font-family: 'Quantico', sans-serif; + text-shadow: 0 0 8px #00ffff, 0 0 16px #00ffff; + border: 2px solid #00ffff; + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.8), 0 0 10px rgba(0, 255, 255, 0.4); + min-height: 44px; + display: flex; + align-items: center; +} + +.gameTime { + background: #1a1a1a; + padding: 10px 16px; + border-radius: 4px; + font-size: 20px; + font-weight: bold; + color: #ff0000; + font-family: "Courier New", "Lucida Console", monospace; + letter-spacing: 3px; + text-shadow: 0 0 8px #ff0000, 0 0 16px #ff0000; + border: 2px solid #333; + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.8), 0 0 8px rgba(255, 0, 0, 0.3); + min-height: 44px; + display: flex; + align-items: center; +} + +.lives { + background: rgba(0, 0, 0, 0.85); + padding: 10px 16px; + border-radius: 4px; + display: flex; + gap: 10px; + align-items: center; + font-family: 'Quantico', sans-serif; + color: #ff00ff; + font-weight: bold; + font-size: 18px; + text-shadow: 0 0 8px #ff00ff, 0 0 16px #ff00ff; + border: 2px solid #ff00ff; + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.8), 0 0 10px rgba(255, 0, 255, 0.4); + min-height: 44px; +} + +.alien { + font-size: 18px; +} + +.alienLost { + opacity: 0.3; + text-decoration: line-through; +} + +.gameOver { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + color: white !important; + text-align: center; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; +} + +.gameOverButton { + position: absolute; + bottom: 20px; + right: 20px; + cursor: pointer; + pointer-events: all; + transition: transform 0.2s ease; + z-index: 10; + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + animation: buttonShine 2s ease-in-out infinite; +} + +.gameOverButton:hover { + transform: scale(1.05); + filter: drop-shadow(0 0 20px rgba(255, 0, 255, 0.8)); +} + +.startScreen { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + color: white !important; + text-align: center; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; +} + +.startScreen h1 { + font-size: 48px; + margin: 0 0 20px 0; + color: white !important; + font-family: 'Quantico', sans-serif; +} + +.startScreen p { + font-size: 18px; + margin: 10px 0; + line-height: 1.5; + color: white !important; + font-family: 'Quantico', sans-serif; +} + +.startScreen button { + margin-top: 20px; + padding: 12px 24px; + font-size: 18px; + background: #4a90e2; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + pointer-events: all; + font-family: 'Quantico', sans-serif; +} + +.startScreen button:hover { + background: #357abd; +} + +.startButton { + position: absolute; + bottom: 20px; + right: 20px; + cursor: pointer; + pointer-events: all; + transition: transform 0.2s ease; + z-index: 10; + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + animation: buttonShine 2s ease-in-out infinite; +} + +.startButton:hover { + transform: scale(1.05); + filter: drop-shadow(0 0 20px rgba(255, 0, 255, 0.8)); +} + +@keyframes buttonShine { + 0%, 100% { + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + } + 50% { + filter: drop-shadow(0 0 20px rgba(0, 255, 255, 0.8)); + } +} + +.checkButton { + margin-top: 12px; + padding: 8px 16px; + font-size: 16px; + background: #00ffff; + color: #000; + border: 2px solid #00ffff; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + font-family: 'Quantico', sans-serif; + box-shadow: 0 0 10px rgba(0, 255, 255, 0.5); + transition: all 0.2s ease; +} + +.checkButton:hover { + background: #00cccc; + box-shadow: 0 0 20px rgba(0, 255, 255, 0.8); + transform: translateY(-1px); +} + +.checkButton:disabled { + background: #333; + color: #666; + border-color: #333; + cursor: not-allowed; + box-shadow: none; +} + +.feedback { + margin-top: 12px; + padding: 12px 16px; + border-radius: 4px; + font-weight: bold; + font-size: 16px; + text-align: center; + font-family: 'Quantico', sans-serif; +} + +.feedbackCorrect { + background: rgba(0, 255, 100, 0.15); + color: #00ff66; + border: 2px solid #00ff66; + text-shadow: 0 0 8px rgba(0, 255, 100, 0.8); + box-shadow: 0 0 15px rgba(0, 255, 100, 0.4); +} + +.feedbackIncorrect { + background: rgba(255, 0, 100, 0.15); + color: #ff0066; + border: 2px solid #ff0066; + text-shadow: 0 0 8px rgba(255, 0, 100, 0.8); + box-shadow: 0 0 15px rgba(255, 0, 100, 0.4); + animation: pulse 0.5s ease-in-out; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +.debugInfo { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.8); + color: #0f0; + padding: 10px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; + max-width: 400px; + z-index: 15; + white-space: pre-wrap; + word-break: break-all; +} + +.timerContainer { + position: relative; + width: 100%; + height: 24px; + background: #e0e0e0; + border-radius: 4px; + margin-bottom: 12px; + overflow: hidden; +} + +.timerBar { + height: 100%; + transition: width 0.1s linear, background-color 0.3s ease; + border-radius: 4px; +} + +.timerText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-weight: bold; + font-size: 14px; + color: #000; + text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); + z-index: 1; + font-family: 'Quantico', sans-serif; +} + +.benevolenceMessage { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 72px; + font-weight: bold; + color: #ff00ff; + text-shadow: 0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px + rgba(255, 0, 255, 0.6); + z-index: 30; + animation: benevolencePulse 2s ease-in-out; + pointer-events: none; + font-family: 'Quantico', sans-serif; +} + +@keyframes benevolencePulse { + 0% { + transform: translate(-50%, -50%) scale(0.5); + opacity: 0; + } + 50% { + transform: translate(-50%, -50%) scale(1.2); + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + } +} + +.storyScreen { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} + +.storyNextButton { + position: absolute; + bottom: 20px; + right: 20px; + cursor: pointer; + pointer-events: all; + transition: transform 0.2s ease; + z-index: 10; + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + animation: buttonShine 2s ease-in-out infinite; +} + +.storyNextButton:hover { + transform: scale(1.05); + filter: drop-shadow(0 0 20px rgba(255, 0, 255, 0.8)); +} + +.victoryScreen { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + display: flex; + align-items: center; + justify-content: center; + z-index: 20; +} + +.victoryButton { + position: absolute; + bottom: 20px; + right: 20px; + cursor: pointer; + pointer-events: all; + transition: transform 0.2s ease; + z-index: 10; + filter: drop-shadow(0 0 10px rgba(255, 0, 255, 0.5)); + animation: buttonShine 2s ease-in-out infinite; +} + +.victoryButton:hover { + transform: scale(1.05); + filter: drop-shadow(0 0 20px rgba(255, 0, 255, 0.8)); +} + diff --git a/packages/perseus/src/__docs__/math-blaster-game.stories.tsx b/packages/perseus/src/__docs__/math-blaster-game.stories.tsx new file mode 100644 index 00000000000..3535ab532a9 --- /dev/null +++ b/packages/perseus/src/__docs__/math-blaster-game.stories.tsx @@ -0,0 +1,1672 @@ +import {scorePerseusItem} from "@khanacademy/perseus-score"; +import {View} from "@khanacademy/wonder-blocks-core"; +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; + +import {storybookDependenciesV2} from "../../../../testing/test-dependencies"; +import {ServerItemRenderer} from "../server-item-renderer"; + +import gameOverAudio from "../games/crash-course/assets/audio/Game Over II.ogg"; +import neonOwlAudio from "../games/crash-course/assets/audio/Zodik - Neon Owl.ogg"; +import tedoxAudio from "../games/crash-course/assets/audio/Zodik - Tedox.ogg"; +import alexBouncyMixAudio from "../games/crash-course/assets/audio/alexbouncymix2.ogg"; +import alien1Img from "../games/crash-course/assets/sprites/alien1.png"; +import alien2Img from "../games/crash-course/assets/sprites/alien2.png"; +import alien3Img from "../games/crash-course/assets/sprites/alien3.png"; +import beamImg from "../games/crash-course/assets/sprites/beam.png"; +import {CarBonusScene} from "./car-bonus-scene"; +import car1Img from "../games/crash-course/assets/sprites/car1.png"; +import cityCloseImg from "../games/crash-course/assets/backgrounds/city-close.png"; +import cityFarImg from "../games/crash-course/assets/backgrounds/city-far.png"; +import citySemiCloseImg from "../games/crash-course/assets/backgrounds/city-semi-close.png"; +import citySemiFarImg from "../games/crash-course/assets/backgrounds/city-semi-far.png"; +import impactImg from "../games/crash-course/assets/sprites/impact.png"; +import lampLightImg from "../games/crash-course/assets/backgrounds/lamplight.png"; +import loseImg from "../games/crash-course/assets/ui/lose.png"; +import styles from "./math-blaster-game.module.css"; +import {createObstacle} from "./math-blaster-utils"; +import muteImg from "../games/crash-course/assets/ui/mute.png"; +import nextImg from "../games/crash-course/assets/ui/next.png"; +import run1Img from "../games/crash-course/assets/sprites/run1.png"; +import run2Img from "../games/crash-course/assets/sprites/run2.png"; +import run3Img from "../games/crash-course/assets/sprites/run3.png"; +import run4Img from "../games/crash-course/assets/sprites/run4.png"; +import run5Img from "../games/crash-course/assets/sprites/run5.png"; +import run6Img from "../games/crash-course/assets/sprites/run6.png"; +import skyImg from "../games/crash-course/assets/backgrounds/sky.png"; +import startImg from "../games/crash-course/assets/ui/start.png"; +import story1Img from "../games/crash-course/assets/story/story1.png"; +import story2Img from "../games/crash-course/assets/story/story2.png"; +import story3Img from "../games/crash-course/assets/story/story3.png"; +import story4Img from "../games/crash-course/assets/story/story4.png"; +import story5Img from "../games/crash-course/assets/story/story5.png"; +import story6Img from "../games/crash-course/assets/story/story6.png"; +import story7Img from "../games/crash-course/assets/story/story7.png"; +import streetLampImg from "../games/crash-course/assets/backgrounds/streetlamp.png"; +import titleImg from "../games/crash-course/assets/ui/title.png"; +import unmuteImg from "../games/crash-course/assets/ui/unmute.png"; +import victoryImg from "../games/crash-course/assets/ui/victory.png"; + +import type {Obstacle} from "./math-blaster-utils"; +import type {PerseusScore} from "@khanacademy/perseus-core"; +import type {Meta, StoryObj} from "@storybook/react-vite"; + +// Game constants +const CANVAS_WIDTH = 800; +const CANVAS_HEIGHT = 600; +const GROUND_Y = 450; +const SCROLL_SPEED = 2; // Slowed down from 3 +const CHARACTER_X = 100; +const SPRITE_SIZE = 128; // Updated to 128x128 for new sprite sheet +const CHARACTER_WIDTH = SPRITE_SIZE; +const CHARACTER_HEIGHT = SPRITE_SIZE; +const JUMP_HEIGHT = 140; // Increased from 150 to clear obstacles better +const JUMP_DURATION = 1000; // ms +const OBSTACLE_SPAWN_INTERVAL = 5000; // Increased from 3000ms to give more time +const COLLISION_ZONE_X = CHARACTER_X + CHARACTER_WIDTH + 20; +const COOL_MODE_DURATION = 2000; // How long cool mode lasts after correct answer +const LAMP_SPACING = 500; // Distance between street lamps +const GAME_DURATION = 300000; // 5 minutes in milliseconds (11:55 to midnight) + +type GameState = + | "start" + | "story" + | "playing" + | "carBonus" + | "gameover" + | "victory"; +type CharacterState = "running" | "coolMode" | "impact" | "loss"; + +type SpriteFrame = { + imageUrl?: string; + color?: string; + label: string; +}; + +// Sprite frame definitions +const SPRITE_FRAMES: Record = { + running: [ + {imageUrl: run1Img, label: "Run 1"}, + {imageUrl: run2Img, label: "Run 2"}, + {imageUrl: run3Img, label: "Run 3"}, + {imageUrl: run4Img, label: "Run 4"}, + {imageUrl: run5Img, label: "Run 5"}, + {imageUrl: run6Img, label: "Run 6"}, + ], + coolMode: [ + {imageUrl: run1Img, color: "#9370DB", label: "Cool 1"}, // Tinted purple (will add guitar overlay) + {imageUrl: run2Img, color: "#8A2BE2", label: "Cool 2"}, + {imageUrl: run3Img, color: "#9400D3", label: "Cool 3"}, + {imageUrl: run4Img, color: "#9370DB", label: "Cool 4"}, + {imageUrl: run5Img, color: "#8A2BE2", label: "Cool 5"}, + {imageUrl: run6Img, color: "#9400D3", label: "Cool 6"}, + ], + impact: [{imageUrl: impactImg, label: "Impact"}], + loss: [{imageUrl: impactImg, label: "Loss"}], +}; + +const MathBlasterGame = (): React.ReactElement => { + const canvasRef = useRef(null); + const itemRendererRef = useRef(null); + const animationFrameRef = useRef(); + const lastSpawnTimeRef = useRef(0); + const gameLoopRef = useRef<() => void>(); + const spriteImagesRef = useRef>(new Map()); + const menuAudioRef = useRef(null); // Menu music (bouncy mix) + const gameAudioRef = useRef(null); // Gameplay music (tedox) + const neonOwlAudioRef = useRef(null); // Extended gameplay music (neon owl) + const gameOverAudioRef = useRef(null); // Game over music + + // Refs for values that game loop needs to read in real-time + const isJumpingRef = useRef(false); + const jumpStartTimeRef = useRef(0); + const characterYRef = useRef(GROUND_Y); + const shakeOffsetRef = useRef({x: 0, y: 0}); + const obstaclesRef = useRef([]); // Shared ref for obstacles + const alienBlinkTimerRef = useRef(0); // Track time until next blink + const alienFrameRef = useRef(1); // Current alien frame for real-time access + const alienTimeRef = useRef(0); // Current alien time for floating motion + const isAlienAbductingRef = useRef(false); // Track abduction mode in real-time + const isAlienReturningRef = useRef(false); // Track returning mode in real-time + const alienReturnStartTimeRef = useRef(0); // When return started + const alienReturnStartPosRef = useRef({x: 0, y: 0}); // Position where return started + const isAlienFlyingAwayRef = useRef(false); // Track flying away mode in real-time + const alienFlyAwayStartTimeRef = useRef(0); // When flyaway started + const totalScrollDistanceRef = useRef(0); // Continuous scroll distance for lamps (never wraps) + const gameStartTimeRef = useRef(0); // When gameplay started (ref for real-time access) + const isMutedRef = useRef(false); // Muted state for real-time access in event listeners + + const [gameState, setGameState] = useState("start"); + const [storyPage, setStoryPage] = useState(1); // Current story page (1-7) + const [score, setScore] = useState(0); + const [gameStartTime, setGameStartTime] = useState(0); // When gameplay started + const [gameTime, setGameTime] = useState("11:55:00"); // Current game time display + const [isMuted, setIsMuted] = useState(false); // Audio mute state + const [lives, setLives] = useState(3); + const [obstacles, setObstacles] = useState([]); + const [currentObstacle, setCurrentObstacle] = useState( + null, + ); + const [isJumping, setIsJumping] = useState(false); + const [jumpStartTime, setJumpStartTime] = useState(0); + const [characterY, setCharacterY] = useState(GROUND_Y); + const [walkFrame, setWalkFrame] = useState(0); + const [userInput, setUserInput] = useState({}); + const [answerFeedback, setAnswerFeedback] = useState<{ + show: boolean; + correct: boolean; + message: string; + }>({show: false, correct: false, message: ""}); + const [debugInfo, setDebugInfo] = useState(""); + const [characterState, setCharacterState] = + useState("running"); + const [coolModeEndTime, setCoolModeEndTime] = useState(0); + const [imagesLoaded, setImagesLoaded] = useState(false); + const [shakeOffset, setShakeOffset] = useState({x: 0, y: 0}); + const [isShaking, setIsShaking] = useState(false); + const [parallaxOffsets, setParallaxOffsets] = useState([0, 0, 0, 0, 0]); + const [alienFrame, setAlienFrame] = useState(1); // Current frame (1, 2, or 3) + const [alienTime, setAlienTime] = useState(0); // Time for floating motion + const [alienBlinkState, setAlienBlinkState] = useState< + "idle" | "blink1" | "blink2" + >("idle"); + const [alienNextBlinkTime, setAlienNextBlinkTime] = useState(2000); // When to next blink (ms) + const [isAlienAbducting, setIsAlienAbducting] = useState(false); // Is alien in abduction mode + const [isAlienReturning, setIsAlienReturning] = useState(false); // Is alien returning to floating position + const [alienReturnStartTime, setAlienReturnStartTime] = useState(0); // When return started + const [alienReturnStartPos, setAlienReturnStartPos] = useState({ + x: 0, + y: 0, + }); // Position where return started + const [isAlienFlyingAway, setIsAlienFlyingAway] = useState(false); // Is alien flying away + const [alienFlyAwayStartTime, setAlienFlyAwayStartTime] = useState(0); // When flyaway started + const [benevolenceMessage, setBenevolenceMessage] = useState(false); // Show "BENEVOLENCE" message + + // Load and setup audio on mount + useEffect(() => { + const menuAudio = new Audio(alexBouncyMixAudio); + menuAudio.loop = true; + menuAudio.volume = 0.5; // Set to 50% volume + menuAudioRef.current = menuAudio; + + const gameAudio = new Audio(tedoxAudio); + gameAudio.loop = false; // Don't loop tedox, we'll transition to neon owl + gameAudio.volume = 0.5; // Set to 50% volume + gameAudioRef.current = gameAudio; + + const neonOwl = new Audio(neonOwlAudio); + neonOwl.loop = true; // Loop neon owl + neonOwl.volume = 0.5; // Set to 50% volume + neonOwlAudioRef.current = neonOwl; + + const gameOver = new Audio(gameOverAudio); + gameOver.loop = false; + gameOver.volume = 0.5; + gameOverAudioRef.current = gameOver; + + // When tedox ends, automatically start neon owl + const handleTedoxEnded = () => { + if (neonOwlAudioRef.current && !isMutedRef.current) { + neonOwlAudioRef.current.currentTime = 0; + neonOwlAudioRef.current.play().catch((error) => { + console.log("Neon Owl audio play failed:", error); + }); + } + }; + gameAudio.addEventListener("ended", handleTedoxEnded); + + return () => { + // Cleanup audio on unmount + gameAudio.removeEventListener("ended", handleTedoxEnded); + if (menuAudioRef.current) { + menuAudioRef.current.pause(); + menuAudioRef.current = null; + } + if (gameAudioRef.current) { + gameAudioRef.current.pause(); + gameAudioRef.current = null; + } + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + neonOwlAudioRef.current = null; + } + if (gameOverAudioRef.current) { + gameOverAudioRef.current.pause(); + gameOverAudioRef.current = null; + } + }; + }, []); + + // Load sprite images on mount + useEffect(() => { + const imagesToLoad = [ + {key: "run1", url: run1Img}, + {key: "run2", url: run2Img}, + {key: "run3", url: run3Img}, + {key: "run4", url: run4Img}, + {key: "run5", url: run5Img}, + {key: "run6", url: run6Img}, + {key: "impact", url: impactImg}, + {key: "alien1", url: alien1Img}, + {key: "alien2", url: alien2Img}, + {key: "alien3", url: alien3Img}, + {key: "beam", url: beamImg}, + {key: "car1", url: car1Img}, + {key: "sky", url: skyImg}, + {key: "cityFar", url: cityFarImg}, + {key: "citySemiFar", url: citySemiFarImg}, + {key: "citySemiClose", url: citySemiCloseImg}, + {key: "cityClose", url: cityCloseImg}, + {key: "streetlamp", url: streetLampImg}, + {key: "lamplight", url: lampLightImg}, + {key: "story1", url: story1Img}, + {key: "story2", url: story2Img}, + {key: "story3", url: story3Img}, + {key: "story4", url: story4Img}, + {key: "story5", url: story5Img}, + {key: "story6", url: story6Img}, + {key: "story7", url: story7Img}, + {key: "victory", url: victoryImg}, + {key: "lose", url: loseImg}, + {key: "title", url: titleImg}, + {key: "start", url: startImg}, + {key: "next", url: nextImg}, + {key: "mute", url: muteImg}, + {key: "unmute", url: unmuteImg}, + ]; + + let loadedCount = 0; + const imageMap = new Map(); + + imagesToLoad.forEach(({key, url}) => { + const img = new Image(); + img.onload = () => { + imageMap.set(key, img); + loadedCount++; + if (loadedCount === imagesToLoad.length) { + spriteImagesRef.current = imageMap; + setImagesLoaded(true); + } + }; + img.onerror = () => { + console.error(`Failed to load sprite: ${key}`); + loadedCount++; + if (loadedCount === imagesToLoad.length) { + setImagesLoaded(true); + } + }; + img.src = url; + }); + }, []); + + // Handle audio playback based on game state + useEffect(() => { + if (isMuted) { + // Mute all audio + if (menuAudioRef.current) { + menuAudioRef.current.pause(); + } + if (gameAudioRef.current) { + gameAudioRef.current.pause(); + } + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + } + if (gameOverAudioRef.current) { + gameOverAudioRef.current.pause(); + } + return; + } + + if (gameState === "start") { + // Play menu music (bouncy mix) on title screen only + if (gameAudioRef.current) { + gameAudioRef.current.pause(); + } + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + } + if (menuAudioRef.current) { + menuAudioRef.current.currentTime = 0; + menuAudioRef.current.play().catch((error) => { + console.log("Menu audio play failed:", error); + }); + } + } else if ( + gameState === "story" || + gameState === "playing" || + gameState === "victory" + ) { + // Play game music (tedox) during story, gameplay, and victory + if (menuAudioRef.current) { + menuAudioRef.current.pause(); + } + if (gameAudioRef.current) { + if (gameState === "story") { + // Restart game music when story starts + gameAudioRef.current.currentTime = 0; + // Also stop neon owl if it was playing + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + } + } + // Ensure it's playing (continues during gameplay and victory) + gameAudioRef.current.play().catch((error) => { + console.log("Game audio play failed:", error); + }); + } + } else if (gameState === "gameover") { + // Play game over music on lose screen + if (menuAudioRef.current) { + menuAudioRef.current.pause(); + } + if (gameAudioRef.current) { + gameAudioRef.current.pause(); + } + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + } + if (gameOverAudioRef.current) { + gameOverAudioRef.current.currentTime = 0; + gameOverAudioRef.current.play().catch((error) => { + console.log("Game over audio play failed:", error); + }); + } + } else { + // Pause all audio (carBonus, etc.) + if (menuAudioRef.current) { + menuAudioRef.current.pause(); + } + if (gameAudioRef.current) { + gameAudioRef.current.pause(); + } + if (neonOwlAudioRef.current) { + neonOwlAudioRef.current.pause(); + } + if (gameOverAudioRef.current) { + gameOverAudioRef.current.pause(); + } + } + }, [gameState, isMuted]); + + // Handle keyboard events for answer submission + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if ( + e.key === "Enter" && + gameState === "playing" && + currentObstacle + ) { + e.preventDefault(); + handleCheckAnswer(); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [gameState, currentObstacle]); + + // Initialize game + const startGame = () => { + setGameState("story"); + setStoryPage(1); // Start at story page 1 + setScore(0); + setLives(3); + setObstacles([]); + setCurrentObstacle(null); + setIsJumping(false); + setCharacterY(GROUND_Y); + setCharacterState("running"); + setCoolModeEndTime(0); + setShakeOffset({x: 0, y: 0}); + setIsShaking(false); + setParallaxOffsets([0, 0, 0, 0, 0]); + setAlienFrame(1); // Start on idle frame + setAlienTime(0); + setAlienBlinkState("idle"); + setIsAlienAbducting(false); + setIsAlienReturning(false); + setAlienReturnStartTime(0); + setAlienReturnStartPos({x: 0, y: 0}); + setIsAlienFlyingAway(false); + setAlienFlyAwayStartTime(0); + setBenevolenceMessage(false); + const initialBlinkDelay = 2000 + Math.random() * 3000; + setAlienNextBlinkTime(initialBlinkDelay); + + // Reset refs + isJumpingRef.current = false; + jumpStartTimeRef.current = 0; + characterYRef.current = GROUND_Y; + shakeOffsetRef.current = {x: 0, y: 0}; + obstaclesRef.current = []; + alienBlinkTimerRef.current = initialBlinkDelay; + alienFrameRef.current = 1; + alienTimeRef.current = 0; + isAlienAbductingRef.current = false; + isAlienReturningRef.current = false; + alienReturnStartTimeRef.current = 0; + alienReturnStartPosRef.current = {x: 0, y: 0}; + isAlienFlyingAwayRef.current = false; + alienFlyAwayStartTimeRef.current = 0; + totalScrollDistanceRef.current = 0; + + lastSpawnTimeRef.current = Date.now(); + + // Stop game over music if playing + if (gameOverAudioRef.current) { + gameOverAudioRef.current.pause(); + gameOverAudioRef.current.currentTime = 0; + } + + // Music will be handled by the useEffect based on game state + }; + + // Handle story next button + const handleStoryNext = () => { + if (storyPage < 7) { + setStoryPage(storyPage + 1); + } else { + // Last story page, transition to gameplay + setGameState("playing"); + const startTime = Date.now(); + setGameStartTime(startTime); // Record when gameplay starts + gameStartTimeRef.current = startTime; // Also set ref for real-time access + } + }; + + // TEMPORARY: Debug button to jump to 11:59:30 (30 seconds before victory) + const jumpToAlmostMidnight = () => { + // Jump to 4.5 minutes (270 seconds) into the game + const targetElapsedMs = 270 * 1000; // 270 seconds + const newStartTime = Date.now() - targetElapsedMs; + setGameStartTime(newStartTime); + gameStartTimeRef.current = newStartTime; // Update ref for real-time access + }; + + // Toggle mute + const toggleMute = () => { + const newMutedState = !isMuted; + setIsMuted(newMutedState); + isMutedRef.current = newMutedState; + }; + + // Shake effect for impact + const triggerShake = () => { + setIsShaking(true); + const duration = 300; // ms + const intensity = 5; // pixels + const startTime = Date.now(); + + const shake = () => { + const elapsed = Date.now() - startTime; + if (elapsed < duration) { + const offset = { + x: (Math.random() - 0.5) * intensity * 2, + y: (Math.random() - 0.5) * intensity * 2, + }; + shakeOffsetRef.current = offset; + setShakeOffset(offset); + requestAnimationFrame(shake); + } else { + shakeOffsetRef.current = {x: 0, y: 0}; + setShakeOffset({x: 0, y: 0}); + setIsShaking(false); + } + }; + shake(); + }; + + // Draw the game + const drawGame = ( + ctx: CanvasRenderingContext2D, + obstaclesList: Obstacle[], + charState: CharacterState, + frame: number, + shake: {x: number; y: number}, + bgOffsets: number[], + alienAnimFrame: number, + alienAnimTime: number, + isAbducting: boolean, + isReturning: boolean, + returnStartTime: number, + returnStartPos: {x: number; y: number}, + isFlyingAway: boolean, + flyAwayStartTime: number, + ) => { + // Clear canvas + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Parallax background layers (back to front) + const backgroundLayers = [ + {key: "sky", speed: 0, height: CANVAS_HEIGHT}, // Sky doesn't scroll + {key: "cityFar", speed: 0.2, height: CANVAS_HEIGHT * 0.6}, + {key: "citySemiFar", speed: 0.4, height: CANVAS_HEIGHT * 0.6}, + {key: "citySemiClose", speed: 0.7, height: CANVAS_HEIGHT * 0.6}, + {key: "cityClose", speed: 1.0, height: CANVAS_HEIGHT * 0.6}, + ]; + + backgroundLayers.forEach(({key, height}, index) => { + const img = spriteImagesRef.current.get(key); + if (img) { + const offset = bgOffsets[index]; + const y = CANVAS_HEIGHT - height; + + // Draw two copies for seamless looping + ctx.drawImage(img, -offset, y, CANVAS_WIDTH, height); + ctx.drawImage( + img, + CANVAS_WIDTH - offset, + y, + CANVAS_WIDTH, + height, + ); + } + }); + + // Draw floating alien in the sky + // alienAnimFrame now directly contains the frame number (1, 2, or 3) + const alienImgKey = `alien${alienAnimFrame}`; + const alienImg = spriteImagesRef.current.get(alienImgKey); + + // Variables to store alien position for tractor beam + let alienXForBeam = 0; + let alienYForBeam = 0; + + if (alienImg) { + const alienSize = 96; // Size of alien sprite + let alienX: number; + let alienY: number; + + if (isFlyingAway) { + // Flying away mode: alien flies off to the upper right + const now = Date.now(); + const flyProgress = (now - flyAwayStartTime) / 2000; // 2 seconds to fly away + const startX = CANVAS_WIDTH * 0.65; // Start from floating position + const startY = 120; + + // Fly up and to the right, accelerating + alienX = + startX + flyProgress * flyProgress * CANVAS_WIDTH * 1.5; // Accelerate horizontally + alienY = startY - flyProgress * flyProgress * 400; // Accelerate upward + } else if (isReturning) { + // Returning mode: smoothly drift back to floating position + const now = Date.now(); + const returnDuration = 1500; // 1.5 seconds to return + const returnProgress = Math.min( + (now - returnStartTime) / returnDuration, + 1, + ); + + // Use ease-out for smooth deceleration + const easedProgress = 1 - Math.pow(1 - returnProgress, 3); + + // Target floating position - calculate what the floating position SHOULD be right now + const alienBaseX = CANVAS_WIDTH * 0.65; + const alienBaseY = 120; + const targetX = + alienBaseX + + Math.sin(alienAnimTime * 0.4) * 80 + + Math.sin(alienAnimTime * 0.7) * 30; + const targetY = + alienBaseY + + Math.sin(alienAnimTime * 0.5) * 50 + + Math.cos(alienAnimTime * 0.3) * 25; + + // Interpolate from start position to target + alienX = + returnStartPos.x + + (targetX - returnStartPos.x) * easedProgress; + alienY = + returnStartPos.y + + (targetY - returnStartPos.y) * easedProgress; + + // Check if return is complete + if (returnProgress >= 1 && isAlienReturningRef.current) { + isAlienReturningRef.current = false; + setIsAlienReturning(false); + } + } else if (isAbducting) { + // Abduction mode: position alien centered above character + const currentY = characterYRef.current; + const abductionOffset = 80; // How far above the character + + alienX = CHARACTER_X + CHARACTER_WIDTH / 2 - alienSize / 2; // Center horizontally over character + alienY = currentY - abductionOffset; // Position above character + } else { + // Normal floating mode + const alienBaseX = CANVAS_WIDTH * 0.65; // Float around 65% across the screen + const alienBaseY = 120; // Height in the sky + + // Create floating motion with sine waves at different frequencies for more dynamic movement + // Combine multiple sine waves for more organic hovering + alienX = + alienBaseX + + Math.sin(alienAnimTime * 0.4) * 80 + // Main horizontal drift + Math.sin(alienAnimTime * 0.7) * 30; // Smaller secondary movement + + alienY = + alienBaseY + + Math.sin(alienAnimTime * 0.5) * 50 + // Main vertical float + Math.cos(alienAnimTime * 0.3) * 25; // Secondary bobbing motion + } + + // Store alien position for tractor beam + alienXForBeam = alienX; + alienYForBeam = alienY; + + // Draw tractor beam behind alien during abduction + if (isAbducting) { + const beamImage = spriteImagesRef.current.get("beam"); + if (beamImage) { + // Beam starts 2/3 down the alien sprite + const beamTopY = alienY + (alienSize * 2) / 3; + const beamCenterX = alienX + alienSize / 2; + + // Beam extends down to the character + const currentY = characterYRef.current; + const charCenterX = CHARACTER_X + CHARACTER_WIDTH / 2; + const charTopY = currentY + CHARACTER_HEIGHT / 2; + + // Calculate beam dimensions + const beamHeight = charTopY - beamTopY; + const beamWidth = 80; // Width of beam at base + + // Draw the beam image stretched to fit + ctx.save(); + ctx.globalAlpha = 0.8; // Slight transparency + ctx.drawImage( + beamImage, + beamCenterX - beamWidth / 2, + beamTopY, + beamWidth, + beamHeight, + ); + ctx.restore(); + } + } + + ctx.drawImage(alienImg, alienX, alienY, alienSize, alienSize); + } + + // Draw ground + ctx.fillStyle = "#3b3d3e"; + ctx.fillRect( + 0, + GROUND_Y + CHARACTER_HEIGHT, + CANVAS_WIDTH, + CANVAS_HEIGHT - (GROUND_Y + CHARACTER_HEIGHT), + ); + + // Draw ground line + ctx.strokeStyle = "#2b2c2d"; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(0, GROUND_Y + CHARACTER_HEIGHT); + ctx.lineTo(CANVAS_WIDTH, GROUND_Y + CHARACTER_HEIGHT); + ctx.stroke(); + + // Draw street lamps + const lampImg = spriteImagesRef.current.get("streetlamp"); + if (lampImg) { + const lampHeight = 270; // Height of lamp post (50% larger) + const lampWidth = 120; // Width of lamp post (50% larger) + const groundLevel = GROUND_Y + CHARACTER_HEIGHT; + + // Use continuous scroll distance for smooth movement + const scrollOffset = totalScrollDistanceRef.current; + + // Draw lamps at regular intervals, accounting for scroll + for ( + let lampX = -(scrollOffset % LAMP_SPACING); + lampX < CANVAS_WIDTH + lampWidth; + lampX += LAMP_SPACING + ) { + ctx.drawImage( + lampImg, + lampX, + groundLevel - lampHeight, + lampWidth, + lampHeight, + ); + } + } + + // Draw character sprite (use ref for current Y position) + const currentY = characterYRef.current; + const frames = SPRITE_FRAMES[charState]; + const frameIndex = + charState === "impact" || charState === "loss" + ? 0 + : Math.floor(frame / 10) % frames.length; // Change frame every 10 ticks + const currentFrame = frames[frameIndex]; + + // Apply shake offset if shaking + const drawX = CHARACTER_X + shake.x; + const drawY = currentY + shake.y; + + // Draw sprite + if (currentFrame.imageUrl) { + // Get the loaded image from the map + const imgKey = + currentFrame.imageUrl === run1Img + ? "run1" + : currentFrame.imageUrl === run2Img + ? "run2" + : currentFrame.imageUrl === run3Img + ? "run3" + : currentFrame.imageUrl === run4Img + ? "run4" + : currentFrame.imageUrl === run5Img + ? "run5" + : currentFrame.imageUrl === run6Img + ? "run6" + : "impact"; + const img = spriteImagesRef.current.get(imgKey); + + if (img) { + // Apply color tinting for cool mode + if (charState === "coolMode" && currentFrame.color) { + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.fillStyle = currentFrame.color; + ctx.fillRect(drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + ctx.globalAlpha = 1.0; + ctx.drawImage(img, drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + ctx.restore(); + } else { + ctx.drawImage(img, drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + } + } else { + // Fallback if image not loaded + ctx.fillStyle = "#FFD700"; + ctx.fillRect(drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + } + } else { + // Use colored rectangle for states without images (impact, loss) + ctx.fillStyle = currentFrame.color || "#FFD700"; + ctx.fillRect(drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + + // Add border for non-image sprites + ctx.strokeStyle = "#000"; + ctx.lineWidth = 2; + ctx.strokeRect(drawX, drawY, SPRITE_SIZE, SPRITE_SIZE); + } + + // Draw obstacles + const carImg = spriteImagesRef.current.get("car1"); + const carOriginalSize = 256; // Original car sprite size + const carScale = 0.6; // Scale to 60% + const carSize = carOriginalSize * carScale; // + + obstaclesList.forEach((obstacle) => { + const carX = obstacle.x; + const carY = GROUND_Y + CHARACTER_HEIGHT - carSize; + + if (carImg) { + // Apply color tint for answered obstacles + if (obstacle.answered) { + ctx.save(); + if (obstacle.correct) { + // Green tint for correct + ctx.globalAlpha = 0.3; + ctx.fillStyle = "#90EE90"; + } else { + // Red tint for incorrect + ctx.globalAlpha = 0.3; + ctx.fillStyle = "#FFB6C1"; + } + ctx.fillRect(carX, carY, carSize, carSize); + ctx.globalAlpha = 1.0; + ctx.restore(); + } + + // Draw car sprite + ctx.drawImage(carImg, carX, carY, carSize, carSize); + } else { + // Fallback to rectangle if car not loaded + ctx.fillStyle = obstacle.answered + ? obstacle.correct + ? "#90EE90" + : "#FFB6C1" + : "#8B4513"; + ctx.fillRect(carX, carY, carSize, carSize); + } + + // Draw question indicator + if (!obstacle.answered) { + ctx.fillStyle = "#FF6B6B"; + ctx.font = "bold 32px Arial"; + ctx.textAlign = "center"; + ctx.fillText("?", obstacle.x + carSize / 2, carY - 10); + } + }); + + // Draw lamp light beams OVER everything + const lampLightImg = spriteImagesRef.current.get("lamplight"); + if (lampLightImg) { + const lampHeight = 270; // Height of lamp post (same as above) + const lampWidth = 120; // Width of lamp post + const groundLevel = GROUND_Y + CHARACTER_HEIGHT; + const beamWidth = 230; // Width of light beam (also scaled up) + const beamHeight = 256; // Fixed height for light beam + + // Use same scroll offset as lamp posts + const scrollOffset = totalScrollDistanceRef.current; + + // Draw light beams at same positions as lamp posts + for ( + let lampX = -(scrollOffset % LAMP_SPACING); + lampX < CANVAS_WIDTH + lampWidth; + lampX += LAMP_SPACING + ) { + // Center the beam on the lamp post + const beamX = lampX + lampWidth / 2 - beamWidth / 2; + + // Beam starts 1/5 down from the top of the lamp + const lampTopY = groundLevel - lampHeight; + const beamStartY = lampTopY + 20; + + // Draw with some transparency so we can see through it + ctx.save(); + ctx.globalAlpha = 0.4; // Semi-transparent light beam + ctx.drawImage( + lampLightImg, + beamX, + beamStartY, + beamWidth, + beamHeight, + ); + ctx.restore(); + } + } + }; + + // Game loop + useEffect(() => { + if (gameState !== "playing") { + return; + } + + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + let localCurrentObstacle = currentObstacle; + let localWalkFrame = walkFrame; + const localScore = score; + let localLives = lives; + let localCharacterState = characterState; + let localCoolModeEndTime = coolModeEndTime; + let localParallaxOffsets = [...parallaxOffsets]; + + const gameLoop = () => { + if (gameState !== "playing") { + return; + } + + const now = Date.now(); + + // Update game timer (use ref for real-time access) + const elapsedMs = now - gameStartTimeRef.current; + if (elapsedMs >= GAME_DURATION) { + // Player survived 5 minutes - VICTORY! + setGameState("victory"); + return; + } + + // Calculate time display (11:55:00 to 00:00:00) + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const startTimeInSeconds = 11 * 3600 + 55 * 60; // 11:55:00 in seconds + const currentTimeInSeconds = startTimeInSeconds + elapsedSeconds; + + // Handle midnight rollover + const hours = Math.floor(currentTimeInSeconds / 3600) % 24; + const minutes = Math.floor((currentTimeInSeconds % 3600) / 60); + const seconds = currentTimeInSeconds % 60; + + const timeString = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + setGameTime(timeString); + + // Update character state based on cool mode timer + if (localCoolModeEndTime > 0 && now > localCoolModeEndTime) { + localCharacterState = "running"; + localCoolModeEndTime = 0; + setCharacterState("running"); + setCoolModeEndTime(0); + } + + // Update parallax background offsets + const parallaxSpeeds = [0, 0.2, 0.4, 0.7, 1.0]; + localParallaxOffsets = localParallaxOffsets.map((offset, index) => { + const newOffset = offset + SCROLL_SPEED * parallaxSpeeds[index]; + // Loop when offset reaches canvas width + return newOffset >= CANVAS_WIDTH ? 0 : newOffset; + }); + setParallaxOffsets(localParallaxOffsets); + + // Update continuous scroll distance for lamps + totalScrollDistanceRef.current += SCROLL_SPEED; + + // Update walk animation + localWalkFrame += 1; + + // Update alien floating time + alienTimeRef.current += 0.02; // Increment time for smooth floating + setAlienTime(alienTimeRef.current); // Also update state for consistency + + // Update alien blink animation (only if not flying away or returning) + if (!isAlienFlyingAwayRef.current && !isAlienReturningRef.current) { + alienBlinkTimerRef.current -= 16; // Subtract ~16ms per frame (assuming 60fps) + } + + if ( + alienBlinkTimerRef.current <= 0 && + !isAlienFlyingAwayRef.current && + !isAlienReturningRef.current + ) { + // Time to blink! Start the blink sequence + setAlienBlinkState("blink1"); + setAlienFrame(2); // Start with frame 2 + alienFrameRef.current = 2; + + // Schedule frame 3 + setTimeout(() => { + setAlienFrame(3); + alienFrameRef.current = 3; + setAlienBlinkState("blink2"); + + // Return to frame 1 after a short delay + setTimeout(() => { + setAlienFrame(1); + alienFrameRef.current = 1; + setAlienBlinkState("idle"); + + // Schedule next blink randomly between 2-5 seconds + const nextBlinkDelay = 2000 + Math.random() * 3000; + alienBlinkTimerRef.current = nextBlinkDelay; + setAlienNextBlinkTime(nextBlinkDelay); + }, 100); // Frame 3 duration + }, 100); // Frame 2 duration + } + + // Handle jumping (read from refs for real-time values) + if (isJumpingRef.current) { + const jumpProgress = now - jumpStartTimeRef.current; + if (jumpProgress < JUMP_DURATION) { + // Parabolic jump + const progress = jumpProgress / JUMP_DURATION; + const jumpHeight = + Math.sin(progress * Math.PI) * JUMP_HEIGHT; + const newY = GROUND_Y - jumpHeight; + characterYRef.current = newY; + setCharacterY(newY); + } else { + // Jump complete - end abduction if it was active + isJumpingRef.current = false; + characterYRef.current = GROUND_Y; + setIsJumping(false); + setCharacterY(GROUND_Y); + + if (isAlienAbductingRef.current) { + // Start return animation + const abductionOffset = 80; + const alienSize = 96; + const startX = + CHARACTER_X + CHARACTER_WIDTH / 2 - alienSize / 2; + const startY = GROUND_Y - abductionOffset; + + isAlienAbductingRef.current = false; + setIsAlienAbducting(false); + + isAlienReturningRef.current = true; + alienReturnStartTimeRef.current = now; + alienReturnStartPosRef.current = {x: startX, y: startY}; + setIsAlienReturning(true); + setAlienReturnStartTime(now); + setAlienReturnStartPos({x: startX, y: startY}); + } + } + } + + // Move obstacles using the ref + obstaclesRef.current = obstaclesRef.current.map((obs) => { + let speed = SCROLL_SPEED; + + // Racing cars accelerate away! + if (obs.racing && obs.racingStartTime) { + const racingDuration = now - obs.racingStartTime; + // Accelerate over 2 seconds, reaching 5x speed + const acceleration = Math.min(racingDuration / 500, 5); // Reaches 5x in 2.5 seconds + speed = SCROLL_SPEED * (1 + acceleration * 2); + } + + return { + ...obs, + x: obs.x - speed, + }; + }); + + // Check for collisions and handle obstacle passing + obstaclesRef.current = obstaclesRef.current.filter((obs) => { + // Remove obstacles that are off-screen + if (obs.x + obs.width < 0) { + // If obstacle passed and character was in impact state, return to running + if ( + obs.answered && + !obs.correct && + localCharacterState === "impact" + ) { + localCharacterState = "running"; + setCharacterState("running"); + } + + if (obs === localCurrentObstacle) { + localCurrentObstacle = null; + setCurrentObstacle(null); + // Clear feedback when obstacle leaves screen + setAnswerFeedback({ + show: false, + correct: false, + message: "", + }); + } + return false; + } + + // Check if obstacle has reached the collision zone + if ( + obs.x < COLLISION_ZONE_X && + obs.x + obs.width > CHARACTER_X + ) { + // Obstacle is in collision zone + if (!obs.answered) { + // Not answered yet - check if alien can still save us + localLives -= 1; + obs.answered = true; + obs.correct = false; + + // Always clear current obstacle on collision + localCurrentObstacle = null; + setCurrentObstacle(null); + setAnswerFeedback({ + show: false, + correct: false, + message: "", + }); + + setLives(localLives); + + if (localLives <= 0) { + // Out of lives! Transition to carBonus immediately + localCharacterState = "impact"; + setCharacterState("impact"); + triggerShake(); + + isAlienFlyingAwayRef.current = true; + alienFlyAwayStartTimeRef.current = now; + setIsAlienFlyingAway(true); + setAlienFlyAwayStartTime(now); + + // Transition to carBonus almost immediately + setTimeout(() => { + setGameState("carBonus"); + setCharacterState("loss"); + // Clear all obstacles for car bonus scene + obstaclesRef.current = []; + setObstacles([]); + }, 100); // Transition immediately to show skid screen + } else { + // Still have lives - alien abduction to the rescue! + if (!isJumpingRef.current) { + isAlienAbductingRef.current = true; + setIsAlienAbducting(true); + + // Show BENEVOLENCE message + setBenevolenceMessage(true); + setTimeout( + () => setBenevolenceMessage(false), + 2000, + ); + + // Start the jump and mark car as racing + obs.jumped = true; + obs.racing = true; + obs.racingStartTime = now; + isJumpingRef.current = true; + jumpStartTimeRef.current = now; + setIsJumping(true); + setJumpStartTime(now); + } + } + } else if (obs.correct && !obs.jumped) { + // Correct answer - trigger jump! + if (!isJumpingRef.current) { + obs.jumped = true; // Mark so we only jump once per obstacle + obs.racing = true; // Car races away + obs.racingStartTime = now; + isJumpingRef.current = true; + jumpStartTimeRef.current = now; + setIsJumping(true); + setJumpStartTime(now); + } + } + } + + return true; + }); + + // Set current obstacle (closest unanswered obstacle that's close enough) + // Only show question when obstacle is reasonably close (within 700px) + const closestObstacle = obstaclesRef.current.find( + (obs) => !obs.answered && obs.x < 700, + ); + if ( + closestObstacle && + closestObstacle.id !== localCurrentObstacle?.id + ) { + // New question - clear feedback + localCurrentObstacle = closestObstacle; + setCurrentObstacle(closestObstacle); + setUserInput({}); + setAnswerFeedback({ + show: false, + correct: false, + message: "", + }); + } + + // Spawn new obstacles + if (now - lastSpawnTimeRef.current > OBSTACLE_SPAWN_INTERVAL) { + const newObstacle = createObstacle(CANVAS_WIDTH); + obstaclesRef.current.push(newObstacle); + lastSpawnTimeRef.current = now; + } + + // Update state with current obstacles + setObstacles([...obstaclesRef.current]); + setWalkFrame(localWalkFrame); + + // Draw (use refs for real-time shake and character Y values) + drawGame( + ctx, + obstaclesRef.current, + localCharacterState, + localWalkFrame, + shakeOffsetRef.current, + localParallaxOffsets, + alienFrameRef.current, + alienTimeRef.current, + isAlienAbductingRef.current, + isAlienReturningRef.current, + alienReturnStartTimeRef.current, + alienReturnStartPosRef.current, + isAlienFlyingAwayRef.current, + alienFlyAwayStartTimeRef.current, + ); + + // Continue loop + animationFrameRef.current = requestAnimationFrame(gameLoop); + }; + + gameLoopRef.current = gameLoop; + gameLoop(); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [gameState]); + + // Handle answer submission + // Handle car bonus scene completion + const handleCarBonusComplete = () => { + setGameState("gameover"); + }; + + const handleCheckAnswer = () => { + if (!currentObstacle) { + return; + } + + const rendererUserInput = itemRendererRef.current?.getUserInput(); + if (!rendererUserInput) { + return; + } + + try { + const scoreResult: PerseusScore = scorePerseusItem( + currentObstacle.question.question, + rendererUserInput, + "en", + ); + + const isCorrect = + scoreResult.type === "points" && + scoreResult.earned === scoreResult.total && + scoreResult.earned > 0; + + if (isCorrect) { + // CRITICAL: Update the obstacles ref directly so game loop sees it immediately + const obstacle = obstaclesRef.current.find( + (obs) => obs.id === currentObstacle.id, + ); + if (obstacle) { + obstacle.answered = true; + obstacle.correct = true; + } + + // Enter cool mode! + setCharacterState("coolMode"); + setCoolModeEndTime(Date.now() + COOL_MODE_DURATION); + + setScore((prev) => prev + 10); + setAnswerFeedback({ + show: true, + correct: true, + message: "Correct! 🎉", + }); + + // Clear current obstacle after a short delay + setTimeout(() => { + setCurrentObstacle(null); + setAnswerFeedback({ + show: false, + correct: false, + message: "", + }); + }, 1000); + } else { + setAnswerFeedback({ + show: true, + correct: false, + message: + scoreResult.type === "invalid" + ? "Please enter an answer!" + : "Try again! You can keep answering until you get it right!", + }); + // Keep feedback visible - don't auto-clear for incorrect answers + } + } catch (error) { + console.error("Error scoring answer:", error); + } + }; + + return ( + +
+ + + {/* Mute Button */} + {(() => { + const muteButton = isMuted + ? spriteImagesRef.current.get("mute") + : spriteImagesRef.current.get("unmute"); + if (muteButton) { + return ( + {isMuted + ); + } + return null; + })()} + + {/* HUD Background */} + {gameState === "playing" && ( +
+ )} + + {/* HUD */} + {gameState === "playing" && ( +
+
{gameTime}
+
Score: {score}
+
+ Alien Benevolence: + 0 + ? styles.alien + : `${styles.alien} ${styles.alienLost}` + } + > + 👽 + + 1 + ? styles.alien + : `${styles.alien} ${styles.alienLost}` + } + > + 👽 + + 2 + ? styles.alien + : `${styles.alien} ${styles.alienLost}` + } + > + 👽 + +
+
+ )} + + {/* TEMPORARY Debug Button - Hidden */} + {/* {gameState === "playing" && ( + + )} */} + + {/* Question Overlay */} + {gameState === "playing" && + currentObstacle && + (() => { + // Find the live obstacle position from the obstacles array + const liveObstacle = obstacles.find( + (obs) => obs.id === currentObstacle.id, + ); + const obstacleX = liveObstacle?.x ?? currentObstacle.x; + const remainingDistance = obstacleX - COLLISION_ZONE_X; + const totalDistance = 700 - COLLISION_ZONE_X; + const progressPercent = Math.max( + 0, + Math.min( + 100, + (remainingDistance / totalDistance) * 100, + ), + ); + const secondsLeft = Math.max( + 0, + remainingDistance / (SCROLL_SPEED * 60), + ); + + return ( +
+ {/* Timer Progress Bar */} +
+
+ totalDistance * 0.66 + ? "#4CAF50" + : remainingDistance > + totalDistance * 0.33 + ? "#FFC107" + : "#F44336", + }} + /> +
+ {secondsLeft.toFixed(1)}s left +
+
+ + + + {answerFeedback.show && ( +
+ {answerFeedback.message} +
+ )} +
+ ); + })()} + + {/* Benevolence Message */} + {benevolenceMessage && ( +
BENEVOLENCE
+ )} + + {/* Car Bonus Scene */} + {gameState === "carBonus" && ( + + )} + + {/* Start Screen */} + {gameState === "start" && ( +
+ {!imagesLoaded &&

Loading sprites...

} + {imagesLoaded && + (() => { + const titleImage = + spriteImagesRef.current.get("title"); + const startButton = + spriteImagesRef.current.get("start"); + + if (titleImage && startButton) { + return ( + <> + Grand Khan Auto + Start Game + + ); + } + return

Loading...

; + })()} +
+ )} + + {/* Story Screen */} + {gameState === "story" && ( +
+ {(() => { + const storyImg = spriteImagesRef.current.get( + `story${storyPage}`, + ); + const nextButton = + spriteImagesRef.current.get("next"); + const startButton = + spriteImagesRef.current.get("start"); + const buttonToUse = + storyPage < 7 ? nextButton : startButton; + + if (storyImg && buttonToUse) { + return ( + <> + {`Story + { + + ); + } + return

Loading story...

; + })()} +
+ )} + + {/* Game Over Screen */} + {gameState === "gameover" && ( +
+ {(() => { + const loseImage = + spriteImagesRef.current.get("lose"); + const startButton = + spriteImagesRef.current.get("start"); + if (loseImage && startButton) { + return ( + <> + Game Over + Play Again + + ); + } + return

Loading game over screen...

; + })()} +
+ )} + + {/* Victory Screen */} + {gameState === "victory" && ( +
+ {(() => { + const victoryImage = + spriteImagesRef.current.get("victory"); + const startButton = + spriteImagesRef.current.get("start"); + if (victoryImage && startButton) { + return ( + <> + Victory! + Play Again + + ); + } + return

Loading victory screen...

; + })()} +
+ )} +
+ + ); +}; + +const meta: Meta = { + title: "Games/Crash Course", + component: MathBlasterGame, + parameters: { + docs: { + description: { + component: + "An endless runner game where players answer math questions to jump over obstacles. " + + "Features Perseus widget integration for questions, with support for addition, subtraction, " + + "multiplication, and division using numeric-input, expression, and radio widgets.", + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/perseus/src/__docs__/math-blaster-utils.ts b/packages/perseus/src/__docs__/math-blaster-utils.ts new file mode 100644 index 00000000000..33e0152725c --- /dev/null +++ b/packages/perseus/src/__docs__/math-blaster-utils.ts @@ -0,0 +1,255 @@ +import {getDefaultAnswerArea} from "@khanacademy/perseus-core"; + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Question types for the math blaster game + */ +export type QuestionType = + | "addition" + | "subtraction" + | "multiplication" + | "division"; + +/** + * Generate a random integer between min and max (inclusive) + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Generate a Perseus item for addition questions + */ +function generateAdditionQuestion(): PerseusItem { + const a = randomInt(1, 50); + const b = randomInt(1, 50); + const answer = a + b; + + return { + question: { + content: `What is $${a} + ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for subtraction questions + */ +function generateSubtractionQuestion(): PerseusItem { + const a = randomInt(10, 50); + const b = randomInt(1, a); // Ensure positive result + const answer = a - b; + + return { + question: { + content: `What is $${a} - ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for multiplication questions + */ +function generateMultiplicationQuestion(): PerseusItem { + const a = randomInt(2, 12); + const b = randomInt(2, 12); + const answer = a * b; + + return { + question: { + content: `What is $${a} \\times ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for division questions + */ +function generateDivisionQuestion(): PerseusItem { + const divisor = randomInt(2, 12); + const quotient = randomInt(2, 12); + const dividend = divisor * quotient; // Ensure exact division + + // Generate wrong answers + const wrongAnswers = [quotient + 1, quotient - 1, quotient + 2].filter( + (val) => val > 0, + ); + + // Shuffle all answers + const allAnswers = [quotient, ...wrongAnswers.slice(0, 3)]; + const shuffled = allAnswers.sort(() => Math.random() - 0.5); + + const choices = shuffled.map((val, index) => ({ + id: `choice-${index}-${Date.now()}-${Math.random()}`, + content: String(val), + correct: val === quotient, + })); + + return { + question: { + content: `What is $${dividend} \\div ${divisor}$?\n\n[[☃ radio 1]]`, + images: {}, + widgets: { + "radio 1": { + type: "radio", + alignment: "default", + static: false, + graded: true, + options: { + choices, + randomize: false, + multipleSelect: false, + hasNoneOfTheAbove: false, + deselectEnabled: false, + }, + version: {major: 1, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a random math question + */ +export function generateQuestion(): PerseusItem { + const types: QuestionType[] = [ + "addition", + "subtraction", + "multiplication", + "division", + ]; + const randomType = types[randomInt(0, types.length - 1)]; + + switch (randomType) { + case "addition": + return generateAdditionQuestion(); + case "subtraction": + return generateSubtractionQuestion(); + case "multiplication": + return generateMultiplicationQuestion(); + case "division": + return generateDivisionQuestion(); + } +} + +/** + * Obstacle data structure + */ +export type Obstacle = { + id: string; + x: number; + y: number; + width: number; + height: number; + question: PerseusItem; + answered: boolean; + correct: boolean; + jumped?: boolean; // Track if we've already triggered jump for this obstacle + racing?: boolean; // Track if car is racing away after being jumped + racingStartTime?: number; // When the car started racing +}; + +/** + * Create a new obstacle + */ +export function createObstacle(startX: number): Obstacle { + return { + id: `obstacle-${Date.now()}-${Math.random()}`, + x: startX, + y: 0, // Ground level + width: 154, // Car sprite at 0.6 scale (256 * 0.6) + height: 154, // Car sprite at 0.6 scale (256 * 0.6) + question: generateQuestion(), + answered: false, + correct: false, + }; +} diff --git a/packages/perseus/src/__docs__/oldimages/car2.png b/packages/perseus/src/__docs__/oldimages/car2.png new file mode 100644 index 00000000000..11632e72cad Binary files /dev/null and b/packages/perseus/src/__docs__/oldimages/car2.png differ diff --git a/packages/perseus/src/__docs__/oldimages/run1.png b/packages/perseus/src/__docs__/oldimages/run1.png new file mode 100644 index 00000000000..fa99c09f7a8 Binary files /dev/null and b/packages/perseus/src/__docs__/oldimages/run1.png differ diff --git a/packages/perseus/src/__docs__/oldimages/run2.png b/packages/perseus/src/__docs__/oldimages/run2.png new file mode 100644 index 00000000000..e811714f4c4 Binary files /dev/null and b/packages/perseus/src/__docs__/oldimages/run2.png differ diff --git a/packages/perseus/src/__docs__/oldimages/run3.png b/packages/perseus/src/__docs__/oldimages/run3.png new file mode 100644 index 00000000000..862150329cc Binary files /dev/null and b/packages/perseus/src/__docs__/oldimages/run3.png differ diff --git a/packages/perseus/src/__docs__/oldimages/sky.png b/packages/perseus/src/__docs__/oldimages/sky.png new file mode 100644 index 00000000000..bdc147b36d9 Binary files /dev/null and b/packages/perseus/src/__docs__/oldimages/sky.png differ diff --git a/packages/perseus/src/games/crash-course/README.md b/packages/perseus/src/games/crash-course/README.md new file mode 100644 index 00000000000..efad8a66d92 --- /dev/null +++ b/packages/perseus/src/games/crash-course/README.md @@ -0,0 +1,119 @@ +# Crash Course - Educational Endless Runner + +**Status**: 🚧 Under active refactoring (Phase 1 in progress) + +Educational endless runner game where players answer math questions while avoiding obstacles. + +## Architecture + +This game uses a **custom game engine** pattern: + +``` +CrashCourseEngine (Pure TypeScript) +├── 60fps game loop (requestAnimationFrame) +├── Physics & collision detection +├── Sprite animation (multi-entity) +├── Canvas rendering +└── Perseus question integration + +React UI (Thin wrapper) +├── Game canvas element +├── UI overlays (start, story, HUD) +├── Perseus question widget +└── Event handling (buttons, mute) +``` + +## Directory Structure + +``` +crash-course/ +├── assets/ # Game assets +│ ├── sprites/ # Character, alien, car sprites +│ ├── backgrounds/ # Sky, city layers, lamps +│ ├── ui/ # Buttons, title, victory/lose screens +│ ├── audio/ # Music and sound effects +│ └── story/ # Story page images +├── engine/ # Game engine (Phase 2) +│ ├── crash-course-engine.ts # Main engine class +│ ├── types.ts # Type definitions +│ ├── systems/ # Reusable systems +│ └── utils/ # Helper functions +├── components/ # React UI components (Phase 3) +├── __tests__/ # Unit tests +├── __testdata__/ # Test fixtures +├── __test-utils__/ # Test helpers +└── plans/ # Refactoring documentation +``` + +## Current Status + +### ✅ Phase 0: Testing Foundation (Complete) +- Baseline tests written +- Performance metrics captured +- Behavior documented + +### 🚧 Phase 1: Setup & Architecture (In Progress) +- [x] Directory structure created +- [x] Assets organized +- [x] Engine API designed +- [x] Perseus integration interface +- [ ] Implementation in Phase 2 + +### 📋 Phase 2: Build Engine (Next) +- Build CrashCourseEngine class +- Implement sprite animation system +- Build game systems (Render, Audio, Assets) +- Move game logic from React + +### 📋 Phase 3: UI Components (Planned) +- Extract React UI components +- Wire up engine callbacks +- Integrate Perseus widget + +### 📋 Phase 4: Documentation (Planned) +- Write comprehensive docs +- Add code comments +- Performance verification + +## Key Design Decisions + +1. **Custom Game Engine**: Avoids React + requestAnimationFrame complexity +2. **Pure TypeScript**: Game logic independent of React +3. **60fps Game Loop**: Decoupled from React rendering +4. **Multi-Entity Sprites**: Built from start to avoid future refactoring +5. **Perseus Integration**: Standard interface for all educational games + +## Documentation + +See `plans/` directory for complete refactoring plan: +- [CRASH_COURSE_CONCEPT.md](./plans/CRASH_COURSE_CONCEPT.md) - Architecture vision +- [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./plans/CRASH_COURSE_IMPLEMENTATION_PLAN.md) - 5-phase plan +- [ORIGINAL_BEHAVIOR.md](./plans/ORIGINAL_BEHAVIOR.md) - Current game behavior +- [PERFORMANCE_BASELINE.md](./plans/PERFORMANCE_BASELINE.md) - Performance metrics + +## Running the Game + +Currently, the original game still exists at: +`packages/perseus/src/__docs__/math-blaster-game.stories.tsx` + +After refactoring (Phase 3), the new version will be at: +`packages/perseus/src/games/crash-course/crash-course.stories.tsx` + +## Testing + +```bash +# Run tests +pnpm test packages/perseus/src/games/crash-course + +# Run specific test file +pnpm test crash-course-utils.test.ts +``` + +## Contributing + +This refactoring follows a 5-phase plan. Please see the phase documents in `plans/` before making changes. + +--- + +**Original**: Math Blaster Game (rapid prototype) +**Refactored**: Crash Course (production-ready, maintainable) diff --git a/packages/perseus/src/games/crash-course/__test-utils__/test-utils.ts b/packages/perseus/src/games/crash-course/__test-utils__/test-utils.ts new file mode 100644 index 00000000000..006e68f44a1 --- /dev/null +++ b/packages/perseus/src/games/crash-course/__test-utils__/test-utils.ts @@ -0,0 +1,201 @@ +/** + * Test utilities for Crash Course game testing + */ + +/** + * Mock canvas element with 2D context + */ +export function mockCanvas(): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 600; + + const ctx = { + // Drawing methods + clearRect: jest.fn(), + fillRect: jest.fn(), + strokeRect: jest.fn(), + beginPath: jest.fn(), + closePath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + arc: jest.fn(), + fill: jest.fn(), + stroke: jest.fn(), + + // Image drawing + drawImage: jest.fn(), + + // Text + fillText: jest.fn(), + strokeText: jest.fn(), + measureText: jest.fn(() => ({width: 100})), + + // Transforms + save: jest.fn(), + restore: jest.fn(), + translate: jest.fn(), + rotate: jest.fn(), + scale: jest.fn(), + transform: jest.fn(), + setTransform: jest.fn(), + + // Properties + fillStyle: "#000000", + strokeStyle: "#000000", + lineWidth: 1, + lineCap: "butt", + lineJoin: "miter", + globalAlpha: 1, + globalCompositeOperation: "source-over", + font: "10px sans-serif", + textAlign: "start", + textBaseline: "alphabetic", + + // Gradients and patterns + createLinearGradient: jest.fn(), + createRadialGradient: jest.fn(), + createPattern: jest.fn(), + + // Pixel manipulation + getImageData: jest.fn(), + putImageData: jest.fn(), + createImageData: jest.fn(), + }; + + // Mock getContext to return our mock context + canvas.getContext = jest.fn((contextType: string) => { + if (contextType === "2d") { + return ctx as any; + } + return null; + }); + + return canvas; +} + +/** + * Mock audio element + */ +export function mockAudio(): HTMLAudioElement { + const audio = { + // Methods + play: jest.fn().mockResolvedValue(undefined), + pause: jest.fn(), + load: jest.fn(), + + // Properties + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + paused: true, + ended: false, + loop: false, + src: "", + + // Events + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + + return audio as any; +} + +/** + * Mock Image element + */ +export function mockImage(src?: string): HTMLImageElement { + const image = { + src: src || "", + width: 128, + height: 128, + complete: true, + addEventListener: jest.fn((event: string, callback: Function) => { + // Immediately trigger onload for testing + if (event === "load") { + setTimeout(() => callback(), 0); + } + }), + removeEventListener: jest.fn(), + }; + + return image as any; +} + +/** + * Advance game time by specified milliseconds + */ +export function advanceGameTime(ms: number): void { + jest.advanceTimersByTime(ms); +} + +/** + * Setup fake timers for testing + */ +export function setupFakeTimers(): void { + jest.useFakeTimers(); +} + +/** + * Restore real timers after testing + */ +export function restoreTimers(): void { + jest.useRealTimers(); +} + +/** + * Mock requestAnimationFrame for testing + */ +export function mockRequestAnimationFrame(): { + mock: jest.Mock; + trigger: () => void; +} { + let callback: FrameRequestCallback | null = null; + let frameId = 1; + + const mock = jest.fn((cb: FrameRequestCallback) => { + callback = cb; + return frameId++; + }); + + const trigger = () => { + if (callback) { + callback(performance.now()); + } + }; + + global.requestAnimationFrame = mock as any; + + return {mock, trigger}; +} + +/** + * Mock cancelAnimationFrame for testing + */ +export function mockCancelAnimationFrame(): jest.Mock { + const mock = jest.fn(); + global.cancelAnimationFrame = mock as any; + return mock; +} + +/** + * Wait for next tick (useful for async operations) + */ +export function waitForNextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +/** + * Create a mock collision scenario + */ +export function createMockCollision( + obstacleX: number, + obstacleWidth: number = 154, + characterX: number = 100, + characterWidth: number = 128, +): boolean { + const collisionZoneX = characterX + characterWidth + 20; + return obstacleX < collisionZoneX && obstacleX + obstacleWidth > characterX; +} diff --git a/packages/perseus/src/games/crash-course/__testdata__/questions.testdata.ts b/packages/perseus/src/games/crash-course/__testdata__/questions.testdata.ts new file mode 100644 index 00000000000..bf66f6dd610 --- /dev/null +++ b/packages/perseus/src/games/crash-course/__testdata__/questions.testdata.ts @@ -0,0 +1,177 @@ +/** + * Test data for Perseus questions used in Crash Course game + */ +import {getDefaultAnswerArea} from "@khanacademy/perseus-core"; + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Example addition question: 12 + 8 = 20 + */ +export const additionQuestion: PerseusItem = { + question: { + content: "What is $12 + 8$?\n\n[[☃ numeric-input 1]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: 20, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], +}; + +/** + * Example subtraction question: 25 - 10 = 15 + */ +export const subtractionQuestion: PerseusItem = { + question: { + content: "What is $25 - 10$?\n\n[[☃ numeric-input 1]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: 15, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], +}; + +/** + * Example multiplication question: 6 × 7 = 42 + */ +export const multiplicationQuestion: PerseusItem = { + question: { + content: "What is $6 \\times 7$?\n\n[[☃ numeric-input 1]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: 42, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], +}; + +/** + * Example division question: 24 ÷ 6 = 4 + */ +export const divisionQuestion: PerseusItem = { + question: { + content: "What is $24 \\div 6$?\n\n[[☃ radio 1]]", + images: {}, + widgets: { + "radio 1": { + type: "radio", + alignment: "default", + static: false, + graded: true, + options: { + choices: [ + { + id: "choice-1", + content: "3", + correct: false, + }, + { + id: "choice-2", + content: "4", + correct: true, + }, + { + id: "choice-3", + content: "5", + correct: false, + }, + { + id: "choice-4", + content: "6", + correct: false, + }, + ], + randomize: false, + multipleSelect: false, + hasNoneOfTheAbove: false, + deselectEnabled: false, + }, + version: {major: 1, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], +}; + +/** + * All question examples for easy iteration + */ +export const allQuestions = [ + additionQuestion, + subtractionQuestion, + multiplicationQuestion, + divisionQuestion, +]; diff --git a/packages/perseus/src/games/crash-course/__tests__/crash-course-utils.test.ts b/packages/perseus/src/games/crash-course/__tests__/crash-course-utils.test.ts new file mode 100644 index 00000000000..f772b00922b --- /dev/null +++ b/packages/perseus/src/games/crash-course/__tests__/crash-course-utils.test.ts @@ -0,0 +1,157 @@ +/** + * Baseline tests for Crash Course utility functions + * + * These tests capture the current behavior before refactoring. + */ +import {createObstacle, generateQuestion} from "../../../__docs__/math-blaster-utils"; + +describe("Crash Course Utils (Baseline)", () => { + describe("generateQuestion", () => { + it("generates a valid Perseus item structure", () => { + //Arrange, Act + const question = generateQuestion(); + + //Assert + expect(question).toHaveProperty("question"); + expect(question).toHaveProperty("answerArea"); + expect(question).toHaveProperty("itemDataVersion"); + expect(question).toHaveProperty("hints"); + expect(question.question).toHaveProperty("content"); + expect(question.question).toHaveProperty("images"); + expect(question.question).toHaveProperty("widgets"); + }); + }); + + describe("createObstacle", () => { + it("creates obstacle at specified x position", () => { + //Arrange, Act + const obstacle = createObstacle(800); + + //Assert + expect(obstacle.x).toBe(800); + }); + + it("creates obstacle with correct dimensions", () => { + //Arrange, Act + const obstacle = createObstacle(500); + + //Assert + expect(obstacle.width).toBe(154); // Car sprite at 0.6 scale + expect(obstacle.height).toBe(154); + expect(obstacle.y).toBe(0); // Ground level + }); + + it("creates obstacle with a question", () => { + //Arrange, Act + const obstacle = createObstacle(600); + + //Assert + expect(obstacle.question).toBeDefined(); + expect(obstacle.question).toHaveProperty("question"); + expect(obstacle.question.question).toHaveProperty("content"); + }); + + it("creates obstacle with unique id", () => { + //Arrange, Act + const obstacle1 = createObstacle(100); + const obstacle2 = createObstacle(200); + + //Assert + expect(obstacle1.id).not.toBe(obstacle2.id); + expect(obstacle1.id).toMatch(/^obstacle-/); + expect(obstacle2.id).toMatch(/^obstacle-/); + }); + + it("creates obstacle with default state", () => { + //Arrange, Act + const obstacle = createObstacle(400); + + //Assert + expect(obstacle.answered).toBe(false); + expect(obstacle.correct).toBe(false); + expect(obstacle.jumped).toBeUndefined(); + expect(obstacle.racing).toBeUndefined(); + }); + + it("creates multiple obstacles with different questions", () => { + //Arrange, Act + const obstacles = Array.from({length: 10}, (_, i) => + createObstacle(i * 100), + ); + + //Assert + // Should have different questions (very unlikely to be all the same) + const uniqueQuestions = new Set( + obstacles.map((o) => o.question.question.content), + ); + expect(uniqueQuestions.size).toBeGreaterThan(1); + }); + }); + + describe("Collision Detection (Logic)", () => { + it("detects collision when obstacle in collision zone", () => { + //Arrange + const obstacleX = 100; + const obstacleWidth = 154; + const characterX = 100; + const characterWidth = 128; + const collisionZoneX = characterX + characterWidth + 20; + + //Act + const isInZone = + obstacleX < collisionZoneX && + obstacleX + obstacleWidth > characterX; + + //Assert + expect(isInZone).toBe(true); + }); + + it("no collision when obstacle far to the right", () => { + //Arrange + const obstacleX = 800; + const obstacleWidth = 154; + const characterX = 100; + const characterWidth = 128; + const collisionZoneX = characterX + characterWidth + 20; + + //Act + const isInZone = + obstacleX < collisionZoneX && + obstacleX + obstacleWidth > characterX; + + //Assert + expect(isInZone).toBe(false); + }); + + it("no collision when obstacle passed character", () => { + //Arrange + const obstacleX = -200; // Passed the character + const obstacleWidth = 154; + const characterX = 100; + const characterWidth = 128; + const collisionZoneX = characterX + characterWidth + 20; + + //Act + const isInZone = + obstacleX < collisionZoneX && + obstacleX + obstacleWidth > characterX; + + //Assert + expect(isInZone).toBe(false); + }); + + it("collision zone is ahead of character", () => { + //Arrange + const characterX = 100; + const characterWidth = 128; + + //Act + const collisionZoneX = characterX + characterWidth + 20; + + //Assert + // Collision zone should be to the right of the character + expect(collisionZoneX).toBe(248); + expect(collisionZoneX).toBeGreaterThan(characterX + characterWidth); + }); + }); +}); diff --git a/packages/perseus/src/games/crash-course/assets/ASSETS.md b/packages/perseus/src/games/crash-course/assets/ASSETS.md new file mode 100644 index 00000000000..f475ec70018 --- /dev/null +++ b/packages/perseus/src/games/crash-course/assets/ASSETS.md @@ -0,0 +1,171 @@ +# Crash Course Assets + +This directory contains all game assets organized by type. + +## Directory Structure + +``` +assets/ +├── sprites/ # Character, alien, and car sprites +├── backgrounds/ # Sky, city layers, and environmental elements +├── ui/ # UI elements (buttons, titles, screens) +├── audio/ # Music tracks and sound effects +└── story/ # Story sequence images +``` + +## Asset Inventory + +### Sprites (`sprites/`) + +**Character Animation (128x128px)**: +- `run1.png` through `run6.png` - Running animation (6 frames) +- `impact.png` - Impact/collision effect + +**Aliens (300x300px)**: +- `alien1.png`, `alien2.png`, `alien3.png` - Alien animation frames +- `beam.png` - Abduction beam effect + +**Obstacles**: +- `car1.png`, `car2.png` - Car obstacles (256x256px) + +### Backgrounds (`backgrounds/`) + +**Sky & City Layers (parallax scrolling)**: +- `sky.png` - Static sky background (800x600px) +- `city-far.png` - Distant city layer (slowest parallax) +- `city-semi-far.png` - Semi-distant city layer +- `city-semi-close.png` - Semi-close city layer +- `city-close.png` - Closest city layer (fastest parallax) + +**Environmental**: +- `streetlamp.png` - Street lamp sprite +- `lamplight.png` - Lamp glow effect + +### UI (`ui/`) + +**Screens**: +- `title.png` - Game title logo +- `start.png` - Start button +- `victory.png` - Victory screen +- `lose.png` - Game over screen + +**Buttons**: +- `next.png` - Next button (story pages) +- `mute.png` - Mute audio icon +- `unmute.png` - Unmute audio icon + +**Special**: +- `bonus1.png`, `bonus2.png` - Car bonus animation frames +- `skid.png` - Skid mark effect + +### Story (`story/`) + +**Story Sequence (7 pages)**: +- `story1.png` through `story7.png` - Narrative intro sequence + +### Audio (`audio/`) + +**Music Tracks (OGG format)**: +- `alexbouncymix2.ogg` - Menu/story music (loops) +- `Zodik - Tedox.ogg` - Gameplay music (plays once) +- `Zodik - Neon Owl.ogg` - Extended gameplay music (loops) +- `Game Over II.ogg` - Game over music + +**Sound Effects (WAV format)**: +- `bonusgame.wav` - Bonus level music +- `explosion.wav` - Explosion sound +- `tires_squal_loop.wav` - Tire squeal sound + +## Import Examples + +### In Engine/Systems (Phase 2) + +```typescript +// Import sprites +import run1 from "../assets/sprites/run1.png"; +import alien1 from "../assets/sprites/alien1.png"; + +// Import backgrounds +import skyImg from "../assets/backgrounds/sky.png"; +import cityFar from "../assets/backgrounds/city-far.png"; + +// Import UI +import titleImg from "../assets/ui/title.png"; +import startBtn from "../assets/ui/start.png"; + +// Import audio +import menuMusic from "../assets/audio/alexbouncymix2.ogg"; +import gameplayMusic from "../assets/audio/Zodik - Tedox.ogg"; + +// Import story +import story1 from "../assets/story/story1.png"; +``` + +### In React Components (Phase 3) + +```typescript +// UI assets for overlays +import titleImg from "../../assets/ui/title.png"; +import startBtn from "../../assets/ui/start.png"; +import muteIcon from "../../assets/ui/mute.png"; +``` + +## Asset Manifest + +For the AssetLoader system (Phase 2), assets will be defined in a manifest: + +```typescript +const ASSET_MANIFEST = { + images: { + // Sprites + run1: run1Img, + run2: run2Img, + // ... etc + + // Backgrounds + sky: skyImg, + cityFar: cityFarImg, + // ... etc + }, + audio: { + menu: menuMusic, + gameplay: gameplayMusic, + extended: neonOwlMusic, + gameover: gameOverMusic, + }, +}; +``` + +## File Formats + +- **Images**: PNG (with transparency where needed) +- **Audio**: OGG Vorbis (web-optimized) + +## Size Guidelines + +- Character sprites: 128x128px +- Alien sprites: 300x300px +- Car sprites: 256x256px +- Backgrounds: 800x600px (full canvas) or wider for parallax +- UI elements: Variable (optimized for 800x600 canvas) + +## Asset Credits + +- Music: Zodik, Alex +- Art: [To be added] + +## Notes for Phase 2 + +When building the AssetLoader: +1. Load all images on init() +2. Create Image elements for each sprite +3. Create Audio elements for each track +4. Track loading progress +5. Resolve promise when all loaded +6. Provide getImage() and getAudio() accessors + +--- + +**All assets moved from**: `packages/perseus/src/__docs__/` +**Moved to**: `packages/perseus/src/games/crash-course/assets/` +**Date**: 2025-01-07 (Phase 1) diff --git a/packages/perseus/src/games/crash-course/assets/audio/Game Over II.ogg b/packages/perseus/src/games/crash-course/assets/audio/Game Over II.ogg new file mode 100644 index 00000000000..39ef03080fa Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/Game Over II.ogg differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/Zodik - Neon Owl.ogg b/packages/perseus/src/games/crash-course/assets/audio/Zodik - Neon Owl.ogg new file mode 100644 index 00000000000..dfd3c000af9 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/Zodik - Neon Owl.ogg differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/Zodik - Tedox.ogg b/packages/perseus/src/games/crash-course/assets/audio/Zodik - Tedox.ogg new file mode 100644 index 00000000000..0471eaac32b Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/Zodik - Tedox.ogg differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/alexbouncymix2.ogg b/packages/perseus/src/games/crash-course/assets/audio/alexbouncymix2.ogg new file mode 100644 index 00000000000..a6af675d887 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/alexbouncymix2.ogg differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/bonusgame.wav b/packages/perseus/src/games/crash-course/assets/audio/bonusgame.wav new file mode 100644 index 00000000000..69bc21dca44 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/bonusgame.wav differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/explosion.wav b/packages/perseus/src/games/crash-course/assets/audio/explosion.wav new file mode 100644 index 00000000000..c78950555f4 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/explosion.wav differ diff --git a/packages/perseus/src/games/crash-course/assets/audio/tires_squal_loop.wav b/packages/perseus/src/games/crash-course/assets/audio/tires_squal_loop.wav new file mode 100644 index 00000000000..d1dc68f4590 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/audio/tires_squal_loop.wav differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/city-close.png b/packages/perseus/src/games/crash-course/assets/backgrounds/city-close.png new file mode 100644 index 00000000000..0eceb3a7523 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/city-close.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/city-far.png b/packages/perseus/src/games/crash-course/assets/backgrounds/city-far.png new file mode 100644 index 00000000000..679a524df22 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/city-far.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-close.png b/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-close.png new file mode 100644 index 00000000000..21d7ec0f656 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-close.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-far.png b/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-far.png new file mode 100644 index 00000000000..f9a19874461 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/city-semi-far.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/lamplight.png b/packages/perseus/src/games/crash-course/assets/backgrounds/lamplight.png new file mode 100644 index 00000000000..42a044bc753 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/lamplight.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/sky.png b/packages/perseus/src/games/crash-course/assets/backgrounds/sky.png new file mode 100644 index 00000000000..0ab0d62a020 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/sky.png differ diff --git a/packages/perseus/src/games/crash-course/assets/backgrounds/streetlamp.png b/packages/perseus/src/games/crash-course/assets/backgrounds/streetlamp.png new file mode 100644 index 00000000000..7b5bc7a1f06 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/backgrounds/streetlamp.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/alien1.png b/packages/perseus/src/games/crash-course/assets/sprites/alien1.png new file mode 100644 index 00000000000..95380d9c4d1 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/alien1.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/alien2.png b/packages/perseus/src/games/crash-course/assets/sprites/alien2.png new file mode 100644 index 00000000000..fd97b662139 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/alien2.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/alien3.png b/packages/perseus/src/games/crash-course/assets/sprites/alien3.png new file mode 100644 index 00000000000..58f37302bd6 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/alien3.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/beam.png b/packages/perseus/src/games/crash-course/assets/sprites/beam.png new file mode 100644 index 00000000000..dfbc540468a Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/beam.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/car1.png b/packages/perseus/src/games/crash-course/assets/sprites/car1.png new file mode 100644 index 00000000000..28c1521ea0c Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/car1.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/car2.png b/packages/perseus/src/games/crash-course/assets/sprites/car2.png new file mode 100644 index 00000000000..0e1f1cb05c2 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/car2.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/impact.png b/packages/perseus/src/games/crash-course/assets/sprites/impact.png new file mode 100644 index 00000000000..06181099b1e Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/impact.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run1.png b/packages/perseus/src/games/crash-course/assets/sprites/run1.png new file mode 100644 index 00000000000..02004bb95aa Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run1.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run2.png b/packages/perseus/src/games/crash-course/assets/sprites/run2.png new file mode 100644 index 00000000000..7877970ae23 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run2.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run3.png b/packages/perseus/src/games/crash-course/assets/sprites/run3.png new file mode 100644 index 00000000000..edab2f86b73 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run3.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run4.png b/packages/perseus/src/games/crash-course/assets/sprites/run4.png new file mode 100644 index 00000000000..36e4e7a2a5f Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run4.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run5.png b/packages/perseus/src/games/crash-course/assets/sprites/run5.png new file mode 100644 index 00000000000..d952cbfbf1e Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run5.png differ diff --git a/packages/perseus/src/games/crash-course/assets/sprites/run6.png b/packages/perseus/src/games/crash-course/assets/sprites/run6.png new file mode 100644 index 00000000000..186e364b04a Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/sprites/run6.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story1.png b/packages/perseus/src/games/crash-course/assets/story/story1.png new file mode 100644 index 00000000000..eba82e8cf51 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story1.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story2.png b/packages/perseus/src/games/crash-course/assets/story/story2.png new file mode 100644 index 00000000000..64722c4df22 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story2.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story3.png b/packages/perseus/src/games/crash-course/assets/story/story3.png new file mode 100644 index 00000000000..231f307e5c0 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story3.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story4.png b/packages/perseus/src/games/crash-course/assets/story/story4.png new file mode 100644 index 00000000000..b32c009f9b5 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story4.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story5.png b/packages/perseus/src/games/crash-course/assets/story/story5.png new file mode 100644 index 00000000000..9fb45e513c2 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story5.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story6.png b/packages/perseus/src/games/crash-course/assets/story/story6.png new file mode 100644 index 00000000000..26f9572ed1c Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story6.png differ diff --git a/packages/perseus/src/games/crash-course/assets/story/story7.png b/packages/perseus/src/games/crash-course/assets/story/story7.png new file mode 100644 index 00000000000..3d0a70d5635 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/story/story7.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/bonus1.png b/packages/perseus/src/games/crash-course/assets/ui/bonus1.png new file mode 100644 index 00000000000..fe45f5a82f5 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/bonus1.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/bonus2.png b/packages/perseus/src/games/crash-course/assets/ui/bonus2.png new file mode 100644 index 00000000000..58cecd17ad3 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/bonus2.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/lose.png b/packages/perseus/src/games/crash-course/assets/ui/lose.png new file mode 100644 index 00000000000..4483bae29f8 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/lose.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/mute.png b/packages/perseus/src/games/crash-course/assets/ui/mute.png new file mode 100644 index 00000000000..a8db1bbae83 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/mute.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/next.png b/packages/perseus/src/games/crash-course/assets/ui/next.png new file mode 100644 index 00000000000..b7b35436331 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/next.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/skid.png b/packages/perseus/src/games/crash-course/assets/ui/skid.png new file mode 100644 index 00000000000..681f4d8b55f Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/skid.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/start.png b/packages/perseus/src/games/crash-course/assets/ui/start.png new file mode 100644 index 00000000000..fe3cc27fc75 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/start.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/title.png b/packages/perseus/src/games/crash-course/assets/ui/title.png new file mode 100644 index 00000000000..11e64ce92b1 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/title.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/unmute.png b/packages/perseus/src/games/crash-course/assets/ui/unmute.png new file mode 100644 index 00000000000..6ff6f881528 Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/unmute.png differ diff --git a/packages/perseus/src/games/crash-course/assets/ui/victory.png b/packages/perseus/src/games/crash-course/assets/ui/victory.png new file mode 100644 index 00000000000..f7972d30ddf Binary files /dev/null and b/packages/perseus/src/games/crash-course/assets/ui/victory.png differ diff --git a/packages/perseus/src/games/crash-course/engine/animated-sprite.ts b/packages/perseus/src/games/crash-course/engine/animated-sprite.ts new file mode 100644 index 00000000000..8258b13ebee --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/animated-sprite.ts @@ -0,0 +1,222 @@ +/** + * Animated Sprite + * + * High-level sprite entity that combines position, size, and animation. + * Makes working with sprites easier by handling position + drawing together. + */ +import {SpriteAnimator} from "./sprite-animator"; + +import type {SpriteEffect} from "./types"; + +/** + * A sprite entity with position, size, and animation + */ +export class AnimatedSprite { + // Position and size + public x: number; + public y: number; + public width: number; + public height: number; + + // Rendering + public layer: string; + public opacity: number = 1; + public visible: boolean = true; + + // Animation + private animator: SpriteAnimator; + + constructor( + animator: SpriteAnimator, + x: number = 0, + y: number = 0, + width: number = 128, + height: number = 128, + layer: string = "default", + ) { + this.animator = animator; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.layer = layer; + } + + /** + * Play an animation + */ + play(name: string, restart: boolean = false): void { + this.animator.play(name, restart); + } + + /** + * Stop the animation + */ + stop(): void { + this.animator.stop(); + } + + /** + * Pause the animation + */ + pause(): void { + this.animator.pause(); + } + + /** + * Resume the animation + */ + resume(): void { + this.animator.resume(); + } + + /** + * Update the animation + */ + update(deltaTime: number): void { + this.animator.update(deltaTime); + } + + /** + * Draw the sprite to a canvas context + */ + draw(ctx: CanvasRenderingContext2D): void { + if (!this.visible) return; + + const frame = this.animator.getCurrentFrame(); + if (!frame) return; + + const effect = this.animator.getEffect(); + + ctx.save(); + + // Apply opacity + ctx.globalAlpha = this.opacity; + + // Apply effects + if (effect) { + this.applyEffect(ctx, effect); + } + + // Draw the sprite + ctx.drawImage(frame, this.x, this.y, this.width, this.height); + + ctx.restore(); + } + + /** + * Apply visual effects + */ + private applyEffect( + ctx: CanvasRenderingContext2D, + effect: SpriteEffect, + ): void { + if (!effect) return; + + switch (effect.type) { + case "shake": + this.x += (Math.random() - 0.5) * effect.intensity; + this.y += (Math.random() - 0.5) * effect.intensity; + break; + + case "tint": + ctx.fillStyle = effect.color; + ctx.globalCompositeOperation = "multiply"; + break; + + case "opacity": + ctx.globalAlpha *= effect.value; + break; + } + } + + /** + * Set a visual effect + */ + setEffect(effect: SpriteEffect): void { + this.animator.setEffect(effect); + } + + /** + * Clear the effect + */ + clearEffect(): void { + this.animator.clearEffect(); + } + + /** + * Get bounding box for collision detection + */ + getBoundingBox(): {x: number; y: number; width: number; height: number} { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }; + } + + /** + * Check if this sprite overlaps with another + */ + overlaps(other: AnimatedSprite): boolean { + return ( + this.x < other.x + other.width && + this.x + this.width > other.x && + this.y < other.y + other.height && + this.y + this.height > other.y + ); + } + + /** + * Check if a point is inside this sprite + */ + containsPoint(x: number, y: number): boolean { + return ( + x >= this.x && + x <= this.x + this.width && + y >= this.y && + y <= this.y + this.height + ); + } + + /** + * Get the center point of the sprite + */ + getCenter(): {x: number; y: number} { + return { + x: this.x + this.width / 2, + y: this.y + this.height / 2, + }; + } + + /** + * Set position + */ + setPosition(x: number, y: number): void { + this.x = x; + this.y = y; + } + + /** + * Move by delta + */ + moveBy(dx: number, dy: number): void { + this.x += dx; + this.y += dy; + } + + /** + * Get current animation name + */ + getCurrentAnimation(): string | null { + return this.animator.getCurrentAnimationName(); + } + + /** + * Check if animating + */ + isAnimating(): boolean { + return this.animator.isAnimating(); + } +} diff --git a/packages/perseus/src/games/crash-course/engine/asset-manifest.ts b/packages/perseus/src/games/crash-course/engine/asset-manifest.ts new file mode 100644 index 00000000000..a0fd689fcd1 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/asset-manifest.ts @@ -0,0 +1,114 @@ +/** + * Asset Manifest + * + * Defines all game assets (images and audio) that need to be loaded. + * Used by AssetLoader to preload and cache assets before game starts. + */ +import type {AssetManifest} from "./types"; + +// Import all sprite assets +import alien1 from "../assets/sprites/alien1.png"; +import alien2 from "../assets/sprites/alien2.png"; +import alien3 from "../assets/sprites/alien3.png"; +import beam from "../assets/sprites/beam.png"; +import car1 from "../assets/sprites/car1.png"; +import impact from "../assets/sprites/impact.png"; +import run1 from "../assets/sprites/run1.png"; +import run2 from "../assets/sprites/run2.png"; +import run3 from "../assets/sprites/run3.png"; +import run4 from "../assets/sprites/run4.png"; +import run5 from "../assets/sprites/run5.png"; +import run6 from "../assets/sprites/run6.png"; + +// Import background assets +import cityClose from "../assets/backgrounds/city-close.png"; +import cityFar from "../assets/backgrounds/city-far.png"; +import citySemiClose from "../assets/backgrounds/city-semi-close.png"; +import citySemiFar from "../assets/backgrounds/city-semi-far.png"; +import lamplight from "../assets/backgrounds/lamplight.png"; +import sky from "../assets/backgrounds/sky.png"; +import streetlamp from "../assets/backgrounds/streetlamp.png"; + +// Import UI assets +import lose from "../assets/ui/lose.png"; +import mute from "../assets/ui/mute.png"; +import next from "../assets/ui/next.png"; +import start from "../assets/ui/start.png"; +import title from "../assets/ui/title.png"; +import unmute from "../assets/ui/unmute.png"; +import victory from "../assets/ui/victory.png"; + +// Import story assets +import story1 from "../assets/story/story1.png"; +import story2 from "../assets/story/story2.png"; +import story3 from "../assets/story/story3.png"; +import story4 from "../assets/story/story4.png"; +import story5 from "../assets/story/story5.png"; +import story6 from "../assets/story/story6.png"; +import story7 from "../assets/story/story7.png"; + +// Import audio assets +import alexBouncyMix from "../assets/audio/alexbouncymix2.ogg"; +import gameOver from "../assets/audio/Game Over II.ogg"; +import neonOwl from "../assets/audio/Zodik - Neon Owl.ogg"; +import tedox from "../assets/audio/Zodik - Tedox.ogg"; + +/** + * Game asset manifest + * Maps asset keys to their file paths + */ +export const ASSET_MANIFEST: AssetManifest = { + images: { + // Character sprites + "run1": run1, + "run2": run2, + "run3": run3, + "run4": run4, + "run5": run5, + "run6": run6, + "impact": impact, + + // Alien sprites + "alien1": alien1, + "alien2": alien2, + "alien3": alien3, + "beam": beam, + + // Obstacle sprites + "car1": car1, + + // Backgrounds + "sky": sky, + "cityFar": cityFar, + "citySemiFar": citySemiFar, + "citySemiClose": citySemiClose, + "cityClose": cityClose, + "streetlamp": streetlamp, + "lamplight": lamplight, + + // UI + "title": title, + "start": start, + "next": next, + "victory": victory, + "lose": lose, + "mute": mute, + "unmute": unmute, + + // Story + "story1": story1, + "story2": story2, + "story3": story3, + "story4": story4, + "story5": story5, + "story6": story6, + "story7": story7, + }, + audio: { + // Music tracks + "menu": alexBouncyMix, + "gameplay": tedox, + "extended": neonOwl, + "gameover": gameOver, + }, +}; diff --git a/packages/perseus/src/games/crash-course/engine/constants.ts b/packages/perseus/src/games/crash-course/engine/constants.ts new file mode 100644 index 00000000000..68caeae939d --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/constants.ts @@ -0,0 +1,88 @@ +/** + * Game Constants + * + * Central location for all game configuration values. + * Makes it easy to tune gameplay and maintain consistency. + */ + +/** + * Canvas dimensions + */ +export const CANVAS_WIDTH = 800; +export const CANVAS_HEIGHT = 600; + +/** + * Ground and positioning + */ +export const GROUND_Y = 450; +export const CHARACTER_X = 100; +export const SPRITE_SIZE = 128; + +/** + * Physics constants + */ +export const JUMP_HEIGHT = 140; +export const JUMP_DURATION = 1000; // milliseconds +export const SCROLL_SPEED = 2; // pixels per frame + +/** + * Gameplay timing + */ +export const OBSTACLE_SPAWN_INTERVAL = 5000; // milliseconds +export const COOL_MODE_DURATION = 2000; // milliseconds +export const GAME_DURATION = 300000; // 5 minutes in milliseconds + +/** + * Collision detection + */ +export const COLLISION_ZONE_X = CHARACTER_X + SPRITE_SIZE + 20; +export const COLLISION_ZONE_WIDTH = 100; +export const QUESTION_DISPLAY_DISTANCE = 700; // Show question when obstacle is this close + +/** + * Parallax scrolling speeds (relative to SCROLL_SPEED) + */ +export const PARALLAX_SPEEDS = { + cityFar: 0.2, + citySemiFar: 0.4, + citySemiClose: 0.6, + cityClose: 0.8, +} as const; + +/** + * Parallax layer Y positions and heights + */ +export const PARALLAX_LAYERS = { + cityFar: {y: 250, height: 200}, + citySemiFar: {y: 300, height: 150}, + citySemiClose: {y: 350, height: 100}, + cityClose: {y: 380, height: 70}, +} as const; + +/** + * Streetlamp configuration + */ +export const LAMP_SPACING = 500; +export const LAMP_LIGHT_Y_OFFSET = -300; + +/** + * Obstacle (car) configuration + */ +export const CAR_WIDTH = 154; // 256 * 0.6 scale +export const CAR_HEIGHT = 154; + +/** + * Character animation FPS + */ +export const CHARACTER_ANIMATION_FPS = 8; + +/** + * Initial player stats + */ +export const STARTING_LIVES = 3; +export const STARTING_SCORE = 0; + +/** + * Cool mode effect color + */ +export const COOL_MODE_TINT = "#9370DB"; // Medium purple diff --git a/packages/perseus/src/games/crash-course/engine/crash-course-engine.ts b/packages/perseus/src/games/crash-course/engine/crash-course-engine.ts new file mode 100644 index 00000000000..d3734dd46e1 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/crash-course-engine.ts @@ -0,0 +1,814 @@ +/** + * Crash Course Game Engine + * + * Main game engine class that runs the 60fps game loop independently of React. + * Handles physics, collision detection, sprite animation, and Perseus question integration. + */ +import {AssetLoader} from "./systems/asset-loader"; +import {AudioSystem} from "./systems/audio-system"; +import {RenderSystem} from "./systems/render-system"; +import {PhysicsSystem} from "./systems/physics-system"; +import {SpriteManager} from "./sprite-manager"; +import {ASSET_MANIFEST} from "./asset-manifest"; +import {generateQuestion} from "./question-generator"; +import * as CONSTANTS from "./constants"; + +import type {PerseusItem} from "@khanacademy/perseus-core"; +import type {PerseusGameEngine} from "../../shared/perseus/perseus-game-engine"; +import type {EngineConfig, GameState, GameUIState, Obstacle} from "./types"; + +/** + * Main game engine for Crash Course + */ +export class CrashCourseEngine implements PerseusGameEngine { + // Canvas + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + + // Callbacks + private onUIUpdate: (state: GameUIState) => void; + private questionChangeCallback: ((question: PerseusItem | null) => void) | null = null; + + // Game loop + private animationFrameId: number | null = null; + private lastTime: number = 0; + private isRunning: boolean = false; + private frameCount: number = 0; + + // Game state + private gameState: GameState = "start"; + private previousGameState: GameState = "start"; + private storyPage: number = 1; + private score: number = 0; + private lives: number = 3; + private gameStartTime: number = 0; + private showBenevolence: boolean = false; + private isMuted: boolean = false; + + // Perseus integration + private currentQuestion: PerseusItem | null = null; + + // Game entities + private obstacles: Obstacle[] = []; + private lastSpawnTime: number = 0; + + // Rendering state + private parallaxOffsets = { + cityFar: 0, + citySemiFar: 0, + citySemiClose: 0, + cityClose: 0, + }; + private lampOffset: number = 0; + + // Engine systems + private assetLoader: AssetLoader; + private audioSystem: AudioSystem; + private renderSystem: RenderSystem; + private physicsSystem: PhysicsSystem; + private spriteManager: SpriteManager; + + // Sprite IDs + private characterSpriteId = "character"; + private alienSpriteId = "alien"; + + constructor(config: EngineConfig) { + this.canvas = config.canvas; + const ctx = this.canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2D context from canvas"); + } + this.ctx = ctx; + this.onUIUpdate = config.onUIUpdate; + + // Initialize systems + this.assetLoader = new AssetLoader(); + this.audioSystem = new AudioSystem(); + this.renderSystem = new RenderSystem(this.canvas); + this.physicsSystem = new PhysicsSystem(CONSTANTS.GROUND_Y); + this.spriteManager = new SpriteManager(this.assetLoader); + } + + /** + * Initialize the game engine and load all assets + */ + async init(): Promise { + // Load all game assets + await this.assetLoader.loadAssets(ASSET_MANIFEST); + + // Register audio tracks with the audio system + const menuAudio = this.assetLoader.getAudio("menu"); + const gameplayAudio = this.assetLoader.getAudio("gameplay"); + const extendedAudio = this.assetLoader.getAudio("extended"); + const gameoverAudio = this.assetLoader.getAudio("gameover"); + + if (menuAudio) this.audioSystem.registerTrack("menu", menuAudio); + if (gameplayAudio) + this.audioSystem.registerTrack("gameplay", gameplayAudio); + if (extendedAudio) + this.audioSystem.registerTrack("extended", extendedAudio); + if (gameoverAudio) + this.audioSystem.registerTrack("gameover", gameoverAudio); + + // Set up audio transitions: when gameplay music ends, start extended + if (gameplayAudio && extendedAudio) { + this.audioSystem.setupTransition("gameplay", "extended"); + } + + // Create character sprite with animations + await this.createCharacterSprite(); + + console.log("Engine initialized - assets loaded"); + } + + /** + * Create character sprite with all animations + */ + private async createCharacterSprite(): Promise { + // Create character sprite with all animation frames + await this.spriteManager.create(this.characterSpriteId, { + frames: ["run1", "run2", "run3", "run4", "run5", "run6", "impact"], + animations: { + running: { + frames: [0, 1, 2, 3, 4, 5], // run1-run6 + fps: CONSTANTS.CHARACTER_ANIMATION_FPS, + loop: true, + }, + coolMode: { + frames: [0, 1, 2, 3, 4, 5], // run1-run6 (will add purple tint) + fps: CONSTANTS.CHARACTER_ANIMATION_FPS, + loop: true, + }, + impact: { + frames: [6], // impact frame + fps: 1, + loop: false, + }, + loss: { + frames: [0], // run1 (character being abducted) + fps: 1, + loop: false, + }, + }, + position: { + x: CONSTANTS.CHARACTER_X, + y: CONSTANTS.GROUND_Y, + }, + size: { + width: CONSTANTS.SPRITE_SIZE, + height: CONSTANTS.SPRITE_SIZE, + }, + layer: "character", + }); + + // Start with running animation + this.spriteManager.play(this.characterSpriteId, "running"); + } + + /** + * Start the game loop + */ + start(): void { + this.isRunning = true; + this.lastTime = performance.now(); + this.gameLoop(this.lastTime); + } + + /** + * Stop the game loop and clean up + */ + stop(): void { + this.isRunning = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + // Clean up systems + this.audioSystem.cleanup(); + this.spriteManager.clear(); + } + + /** + * Pause the game + */ + pause(): void { + this.isRunning = false; + } + + /** + * Resume the game + */ + resume(): void { + if (!this.isRunning) { + this.isRunning = true; + this.lastTime = performance.now(); + this.gameLoop(this.lastTime); + } + } + + /** + * Main game loop - runs at 60fps + */ + private gameLoop = (currentTime: number): void => { + if (!this.isRunning) return; + + const deltaTime = currentTime - this.lastTime; + this.lastTime = currentTime; + + // Update game logic + this.update(deltaTime); + + // Render to canvas + this.render(); + + // Notify React of UI state changes (throttled to ~10fps) + this.frameCount++; + if (this.frameCount % 6 === 0) { + this.notifyUIUpdate(); + } + + // Continue loop + this.animationFrameId = requestAnimationFrame(this.gameLoop); + }; + + /** + * Update game logic + */ + private update(deltaTime: number): void { + // Only update game logic when playing + if (this.gameState !== "playing") { + return; + } + + // Update physics (jumping, character state) + this.physicsSystem.update(deltaTime); + + // Sync character sprite with physics state + this.syncCharacterSprite(); + + // Update sprite animations + this.spriteManager.updateAll(deltaTime); + + // Update parallax scrolling + this.parallaxOffsets.cityFar += + CONSTANTS.SCROLL_SPEED * CONSTANTS.PARALLAX_SPEEDS.cityFar; + this.parallaxOffsets.citySemiFar += + CONSTANTS.SCROLL_SPEED * CONSTANTS.PARALLAX_SPEEDS.citySemiFar; + this.parallaxOffsets.citySemiClose += + CONSTANTS.SCROLL_SPEED * CONSTANTS.PARALLAX_SPEEDS.citySemiClose; + this.parallaxOffsets.cityClose += + CONSTANTS.SCROLL_SPEED * CONSTANTS.PARALLAX_SPEEDS.cityClose; + this.lampOffset += CONSTANTS.SCROLL_SPEED; + + // Update obstacles (move them left) + const currentTime = Date.now(); + + this.obstacles = this.obstacles.filter((obs) => { + obs.x -= CONSTANTS.SCROLL_SPEED; + + // Check collision if obstacle is in collision zone and not answered + if ( + !obs.answered && + !obs.jumped && + this.physicsSystem.checkCollisionZone(obs) + ) { + // If character is jumping, mark as jumped + if (this.physicsSystem.isJumping()) { + obs.jumped = true; + obs.racing = true; + obs.racingStartTime = currentTime; + } + // If not jumping and colliding, trigger impact (unless in cool mode) + else if (this.physicsSystem.getState() !== "coolMode") { + const collision = this.physicsSystem.checkCollision(obs); + if (collision) { + this.physicsSystem.triggerImpact(); + // TODO: Handle collision consequences + } + } + } + + // Remove obstacles that are off-screen + return obs.x + obs.width > 0; + }); + + // Spawn new obstacles + if ( + currentTime - this.lastSpawnTime > + CONSTANTS.OBSTACLE_SPAWN_INTERVAL + ) { + this.spawnObstacle(); + this.lastSpawnTime = currentTime; + } + + // Update current question based on closest obstacle + this.updateCurrentQuestion(); + + // Check victory condition (5 minutes elapsed) + if (currentTime - this.gameStartTime >= CONSTANTS.GAME_DURATION) { + this.transitionToState("victory"); + } + } + + /** + * Render everything to canvas + */ + private render(): void { + // Clear canvas + this.renderSystem.clear(); + + // Only render game visuals during playing state + if (this.gameState !== "playing") { + return; + } + + // Draw sky background (static) + const skyImg = this.assetLoader.getImage("sky"); + if (skyImg) { + this.renderSystem.drawImage( + skyImg, + 0, + 0, + CONSTANTS.CANVAS_WIDTH, + CONSTANTS.CANVAS_HEIGHT, + ); + } + + // Draw parallax city layers + const cityFarImg = this.assetLoader.getImage("cityFar"); + const citySemiFarImg = this.assetLoader.getImage("citySemiFar"); + const citySemiCloseImg = this.assetLoader.getImage("citySemiClose"); + const cityCloseImg = this.assetLoader.getImage("cityClose"); + + if (cityFarImg) { + this.renderSystem.drawParallaxLayer( + cityFarImg, + this.parallaxOffsets.cityFar, + CONSTANTS.PARALLAX_LAYERS.cityFar.y, + CONSTANTS.PARALLAX_LAYERS.cityFar.height, + ); + } + if (citySemiFarImg) { + this.renderSystem.drawParallaxLayer( + citySemiFarImg, + this.parallaxOffsets.citySemiFar, + CONSTANTS.PARALLAX_LAYERS.citySemiFar.y, + CONSTANTS.PARALLAX_LAYERS.citySemiFar.height, + ); + } + if (citySemiCloseImg) { + this.renderSystem.drawParallaxLayer( + citySemiCloseImg, + this.parallaxOffsets.citySemiClose, + CONSTANTS.PARALLAX_LAYERS.citySemiClose.y, + CONSTANTS.PARALLAX_LAYERS.citySemiClose.height, + ); + } + if (cityCloseImg) { + this.renderSystem.drawParallaxLayer( + cityCloseImg, + this.parallaxOffsets.cityClose, + CONSTANTS.PARALLAX_LAYERS.cityClose.y, + CONSTANTS.PARALLAX_LAYERS.cityClose.height, + ); + } + + // Draw ground + this.renderSystem.drawRect( + 0, + CONSTANTS.GROUND_Y, + CONSTANTS.CANVAS_WIDTH, + 150, + "#2a2a2a", + ); + + // Draw streetlamps with lights + this.drawStreetlamps(); + + // Draw obstacles (cars) + this.drawObstacles(); + + // Draw character + this.drawCharacter(); + } + + /** + * Draw streetlamps with their lights + */ + private drawStreetlamps(): void { + const streetlampImg = this.assetLoader.getImage("streetlamp"); + const lamplightImg = this.assetLoader.getImage("lamplight"); + + if (!streetlampImg || !lamplightImg) { + return; + } + + // Calculate how many lamps we need to cover the screen plus overflow + const numLamps = + Math.ceil(CONSTANTS.CANVAS_WIDTH / CONSTANTS.LAMP_SPACING) + 2; + + for (let i = 0; i < numLamps; i++) { + const baseX = i * CONSTANTS.LAMP_SPACING; + const x = baseX - (this.lampOffset % CONSTANTS.LAMP_SPACING); + + // Draw lamplight glow + this.renderSystem.drawImage( + lamplightImg, + x - lamplightImg.width / 2, + CONSTANTS.GROUND_Y + CONSTANTS.LAMP_LIGHT_Y_OFFSET, + lamplightImg.width, + lamplightImg.height, + ); + + // Draw streetlamp + this.renderSystem.drawImage( + streetlampImg, + x - streetlampImg.width / 2, + CONSTANTS.GROUND_Y - streetlampImg.height, + streetlampImg.width, + streetlampImg.height, + ); + } + } + + /** + * Draw obstacles (cars) + */ + private drawObstacles(): void { + const carImg = this.assetLoader.getImage("car1"); + + if (!carImg) { + return; + } + + for (const obs of this.obstacles) { + // Calculate Y position based on ground + const carY = CONSTANTS.GROUND_Y - obs.height; + + // Draw car at obstacle position + this.renderSystem.drawImage( + carImg, + obs.x, + carY, + obs.width, + obs.height, + ); + } + } + + /** + * Sync character sprite with physics state + */ + private syncCharacterSprite(): void { + const characterPhysics = this.physicsSystem.getCharacterState(); + const characterSprite = this.spriteManager.get(this.characterSpriteId); + + if (!characterSprite) { + return; + } + + // Update position to match physics + characterSprite.setPosition(characterPhysics.x, characterPhysics.y); + + // Update animation based on state + const currentAnim = characterSprite.getCurrentAnimation(); + const desiredAnim = this.getCharacterAnimation(characterPhysics.state); + + if (currentAnim !== desiredAnim) { + this.spriteManager.play(this.characterSpriteId, desiredAnim); + + // Add purple tint for cool mode + if (characterPhysics.state === "coolMode") { + characterSprite.setEffect({ + type: "tint", + color: CONSTANTS.COOL_MODE_TINT, + }); + } else { + characterSprite.clearEffect(); + } + } + } + + /** + * Get animation name for character state + */ + private getCharacterAnimation(state: CharacterState): string { + switch (state) { + case "running": + return "running"; + case "coolMode": + return "coolMode"; + case "impact": + return "impact"; + case "loss": + return "loss"; + default: + return "running"; + } + } + + /** + * Draw character using sprite system + */ + private drawCharacter(): void { + const characterSprite = this.spriteManager.get(this.characterSpriteId); + + if (!characterSprite) { + return; + } + + // Draw character sprite + characterSprite.draw(this.ctx); + } + + /** + * Notify React of UI state changes + */ + private notifyUIUpdate(): void { + this.onUIUpdate(this.getUIState()); + } + + /** + * Spawn a new obstacle with a question + */ + private spawnObstacle(): void { + const obstacle: Obstacle = { + id: `obstacle-${Date.now()}-${Math.random()}`, + x: CONSTANTS.CANVAS_WIDTH, + y: 0, // Ground level (will adjust in rendering) + width: CONSTANTS.CAR_WIDTH, + height: CONSTANTS.CAR_HEIGHT, + question: generateQuestion(), + answered: false, + correct: false, + }; + this.obstacles.push(obstacle); + } + + /** + * Update current question based on closest obstacle + * Question is shown when obstacle is within threshold distance + */ + private updateCurrentQuestion(): void { + const closestObstacle = this.obstacles.find( + (obs) => + !obs.answered && obs.x < CONSTANTS.QUESTION_DISPLAY_DISTANCE, + ); + + // If we have a new obstacle to show, update current question + if ( + closestObstacle && + closestObstacle.question !== this.currentQuestion + ) { + this.currentQuestion = closestObstacle.question; + this.notifyQuestionChange(); + } else if (!closestObstacle && this.currentQuestion !== null) { + // No more close obstacles, clear question + this.currentQuestion = null; + this.notifyQuestionChange(); + } + } + + /** + * Get current UI state for React + */ + private getUIState(): GameUIState { + return { + gameState: this.gameState, + storyPage: this.storyPage, + score: this.score, + lives: this.lives, + gameTime: this.formatGameTime(), + currentQuestion: this.currentQuestion, + showBenevolence: this.showBenevolence, + isMuted: this.isMuted, + }; + } + + /** + * Format game time for display + */ + private formatGameTime(): string { + if (this.gameState !== "playing" && this.gameState !== "victory") { + return "11:55:00"; + } + + const elapsed = Date.now() - this.gameStartTime; + const totalSeconds = Math.floor(elapsed / 1000); + const minutes = 55 + Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + // Game lasts 5 minutes (until midnight) + if (minutes >= 60) { + return "12:00:00"; + } + + return `11:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + + /** + * Handle user actions from React (button clicks, etc.) + */ + handleAction(action: string, data?: any): void { + switch (action) { + case "startGame": + this.transitionToState("story"); + break; + case "nextStoryPage": + if (this.storyPage < 7) { + this.storyPage++; + } else { + this.transitionToState("playing"); + } + break; + case "jump": + // Allow jumping during gameplay + if (this.gameState === "playing") { + this.physicsSystem.jump(); + } + break; + case "toggleMute": + this.isMuted = !this.isMuted; + this.audioSystem.setMuted(this.isMuted); + break; + case "restart": + this.transitionToState("start"); + break; + default: + console.warn(`Unknown action: ${action}`); + } + } + + /** + * Transition to a new game state with proper entry/exit logic + */ + private transitionToState(newState: GameState): void { + if (this.gameState === newState) { + return; + } + + // Exit current state + this.exitState(this.gameState); + + // Update state + this.previousGameState = this.gameState; + this.gameState = newState; + + // Enter new state + this.enterState(newState); + } + + /** + * Handle state exit logic + */ + private exitState(state: GameState): void { + switch (state) { + case "playing": + // Clean up gameplay state + this.obstacles = []; + this.currentQuestion = null; + break; + } + } + + /** + * Handle state entry logic + */ + private enterState(state: GameState): void { + switch (state) { + case "start": + // Reset to menu + this.storyPage = 1; + this.score = CONSTANTS.STARTING_SCORE; + this.lives = CONSTANTS.STARTING_LIVES; + this.gameStartTime = 0; + this.lastSpawnTime = 0; + this.obstacles = []; + this.currentQuestion = null; + this.showBenevolence = false; + this.physicsSystem.reset(); + // Play menu music + this.audioSystem.play("menu"); + break; + + case "story": + // Start story sequence + this.storyPage = 1; + this.physicsSystem.reset(); + // Start gameplay music when story begins + this.audioSystem.play("gameplay"); + break; + + case "playing": + // Start gameplay + this.gameStartTime = Date.now(); + this.lastSpawnTime = Date.now(); + this.score = CONSTANTS.STARTING_SCORE; + this.lives = CONSTANTS.STARTING_LIVES; + this.obstacles = []; + this.currentQuestion = null; + this.physicsSystem.resetToRunning(); + // Reset parallax offsets + this.parallaxOffsets = { + cityFar: 0, + citySemiFar: 0, + citySemiClose: 0, + cityClose: 0, + }; + this.lampOffset = 0; + // Reset character sprite animation + this.spriteManager.play(this.characterSpriteId, "running"); + const characterSprite = this.spriteManager.get( + this.characterSpriteId, + ); + if (characterSprite) { + characterSprite.clearEffect(); + } + // Gameplay music should already be playing from story state + break; + + case "victory": + // Victory state - music continues playing + break; + + case "gameover": + // Game over - play game over music + this.audioSystem.play("gameover"); + break; + + case "carBonus": + // Bonus scene (not implemented yet) + break; + } + } + + // Perseus Integration Interface + + /** + * Get the currently active Perseus question + */ + getCurrentQuestion(): PerseusItem | null { + return this.currentQuestion; + } + + /** + * Submit an answer to the current question + */ + submitAnswer(correct: boolean, earnedPoints: number): void { + if (!this.currentQuestion) { + return; + } + + // Find the obstacle with this question + const obstacle = this.obstacles.find( + (obs) => obs.question === this.currentQuestion && !obs.answered, + ); + + if (!obstacle) { + return; + } + + // Mark obstacle as answered + obstacle.answered = true; + obstacle.correct = correct; + + if (correct) { + // Correct answer: increase score, trigger cool mode + this.score += earnedPoints; + this.physicsSystem.triggerCoolMode(); + } else { + // Incorrect answer: lose a life, trigger alien abduction + this.lives = Math.max(0, this.lives - 1); + this.physicsSystem.triggerLoss(); + // TODO: Task 13 - Trigger alien abduction animation + + // Check for game over + if (this.lives === 0) { + this.transitionToState("gameover"); + } + } + + // Clear current question + this.currentQuestion = null; + this.notifyQuestionChange(); + } + + /** + * Register a callback for when the question changes + */ + onQuestionChange( + callback: (question: PerseusItem | null) => void, + ): void { + this.questionChangeCallback = callback; + } + + /** + * Notify React that the question has changed + */ + private notifyQuestionChange(): void { + if (this.questionChangeCallback) { + this.questionChangeCallback(this.currentQuestion); + } + } +} diff --git a/packages/perseus/src/games/crash-course/engine/index.ts b/packages/perseus/src/games/crash-course/engine/index.ts new file mode 100644 index 00000000000..e4898a96ca9 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/index.ts @@ -0,0 +1,13 @@ +/** + * Crash Course Game Engine + * + * Exports the main engine class and types for external use. + */ +export {CrashCourseEngine} from "./crash-course-engine"; +export type { + GameState, + GameUIState, + EngineConfig, + Obstacle, + CharacterState, +} from "./types"; diff --git a/packages/perseus/src/games/crash-course/engine/question-generator.ts b/packages/perseus/src/games/crash-course/engine/question-generator.ts new file mode 100644 index 00000000000..159aa82d7d5 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/question-generator.ts @@ -0,0 +1,228 @@ +/** + * Question Generator + * + * Generates random Perseus math questions for the game. + * Questions include addition, subtraction, multiplication, and division. + */ +import {getDefaultAnswerArea} from "@khanacademy/perseus-core"; + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Question types for the math blaster game + */ +export type QuestionType = + | "addition" + | "subtraction" + | "multiplication" + | "division"; + +/** + * Generate a random integer between min and max (inclusive) + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * Generate a Perseus item for addition questions + */ +function generateAdditionQuestion(): PerseusItem { + const a = randomInt(1, 50); + const b = randomInt(1, 50); + const answer = a + b; + + return { + question: { + content: `What is $${a} + ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for subtraction questions + */ +function generateSubtractionQuestion(): PerseusItem { + const a = randomInt(10, 50); + const b = randomInt(1, a); // Ensure positive result + const answer = a - b; + + return { + question: { + content: `What is $${a} - ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for multiplication questions + */ +function generateMultiplicationQuestion(): PerseusItem { + const a = randomInt(2, 12); + const b = randomInt(2, 12); + const answer = a * b; + + return { + question: { + content: `What is $${a} \\times ${b}$?\n\n[[☃ numeric-input 1]]`, + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + alignment: "default", + static: false, + graded: true, + options: { + answers: [ + { + value: answer, + status: "correct", + message: "", + strict: false, + simplify: "optional", + maxError: 0, + }, + ], + size: "normal", + coefficient: false, + labelText: "", + }, + version: {major: 0, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a Perseus item for division questions + */ +function generateDivisionQuestion(): PerseusItem { + const divisor = randomInt(2, 12); + const quotient = randomInt(2, 12); + const dividend = divisor * quotient; // Ensure exact division + + // Generate wrong answers + const wrongAnswers = [quotient + 1, quotient - 1, quotient + 2].filter( + (val) => val > 0, + ); + + // Shuffle all answers + const allAnswers = [quotient, ...wrongAnswers.slice(0, 3)]; + const shuffled = allAnswers.sort(() => Math.random() - 0.5); + + const choices = shuffled.map((val, index) => ({ + id: `choice-${index}-${Date.now()}-${Math.random()}`, + content: String(val), + correct: val === quotient, + })); + + return { + question: { + content: `What is $${dividend} \\div ${divisor}$?\n\n[[☃ radio 1]]`, + images: {}, + widgets: { + "radio 1": { + type: "radio", + alignment: "default", + static: false, + graded: true, + options: { + choices, + randomize: false, + multipleSelect: false, + hasNoneOfTheAbove: false, + deselectEnabled: false, + }, + version: {major: 1, minor: 0}, + }, + }, + }, + answerArea: getDefaultAnswerArea(), + itemDataVersion: {major: 0, minor: 1}, + hints: [], + }; +} + +/** + * Generate a random math question + */ +export function generateQuestion(): PerseusItem { + const types: QuestionType[] = [ + "addition", + "subtraction", + "multiplication", + "division", + ]; + const randomType = types[randomInt(0, types.length - 1)]; + + switch (randomType) { + case "addition": + return generateAdditionQuestion(); + case "subtraction": + return generateSubtractionQuestion(); + case "multiplication": + return generateMultiplicationQuestion(); + case "division": + return generateDivisionQuestion(); + } +} diff --git a/packages/perseus/src/games/crash-course/engine/sprite-animator.ts b/packages/perseus/src/games/crash-course/engine/sprite-animator.ts new file mode 100644 index 00000000000..32b6188549e --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/sprite-animator.ts @@ -0,0 +1,234 @@ +/** + * Sprite Animator + * + * Low-level animation controller for frame sequencing. + * Handles frame timing, looping, and effects for sprite animations. + */ +import type {SpriteAnimationConfig, SpriteEffect} from "./types"; + +/** + * Low-level sprite animation controller + */ +export class SpriteAnimator { + private frames: HTMLImageElement[] = []; + private animations: Map = new Map(); + private currentAnimation: string | null = null; + private currentFrameIndex: number = 0; + private frameTime: number = 0; + private frameDuration: number = 125; // Default 8fps (1000ms / 8) + private isPlaying: boolean = false; + private loop: boolean = true; + private onComplete: (() => void) | null = null; + private effect: SpriteEffect = null; + + /** + * Load individual frame images + */ + async loadFrames(images: HTMLImageElement[]): Promise { + this.frames = images; + } + + /** + * Load frames from a spritesheet + * @param image - The spritesheet image + * @param config - Grid configuration {cols, rows, frameWidth, frameHeight} + */ + async loadSpritesheet( + image: HTMLImageElement, + config: { + cols: number; + rows: number; + frameWidth: number; + frameHeight: number; + }, + ): Promise { + const {cols, rows, frameWidth, frameHeight} = config; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2D context"); + } + + canvas.width = frameWidth; + canvas.height = frameHeight; + + this.frames = []; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + ctx.clearRect(0, 0, frameWidth, frameHeight); + ctx.drawImage( + image, + col * frameWidth, + row * frameHeight, + frameWidth, + frameHeight, + 0, + 0, + frameWidth, + frameHeight, + ); + + const frameImage = new Image(); + frameImage.src = canvas.toDataURL(); + await new Promise((resolve) => { + frameImage.onload = () => resolve(); + }); + this.frames.push(frameImage); + } + } + } + + /** + * Define a named animation sequence + */ + addAnimation(name: string, config: SpriteAnimationConfig): void { + this.animations.set(name, { + frames: config.frames, + fps: config.fps ?? 8, + loop: config.loop ?? true, + onComplete: config.onComplete, + }); + } + + /** + * Play an animation + */ + play(name: string, restart: boolean = false): void { + if (!this.animations.has(name)) { + console.warn(`Animation "${name}" not found`); + return; + } + + if (this.currentAnimation === name && !restart) { + return; // Already playing this animation + } + + const anim = this.animations.get(name)!; + this.currentAnimation = name; + this.currentFrameIndex = 0; + this.frameTime = 0; + this.frameDuration = 1000 / anim.fps; + this.loop = anim.loop; + this.onComplete = anim.onComplete ?? null; + this.isPlaying = true; + } + + /** + * Stop the current animation + */ + stop(): void { + this.isPlaying = false; + this.currentAnimation = null; + this.currentFrameIndex = 0; + } + + /** + * Pause the animation + */ + pause(): void { + this.isPlaying = false; + } + + /** + * Resume the animation + */ + resume(): void { + if (this.currentAnimation) { + this.isPlaying = true; + } + } + + /** + * Update animation (call every frame with deltaTime) + */ + update(deltaTime: number): void { + if (!this.isPlaying || !this.currentAnimation) { + return; + } + + this.frameTime += deltaTime; + + if (this.frameTime >= this.frameDuration) { + this.frameTime -= this.frameDuration; + this.advanceFrame(); + } + } + + /** + * Advance to the next frame + */ + private advanceFrame(): void { + if (!this.currentAnimation) { + return; + } + + const anim = this.animations.get(this.currentAnimation)!; + this.currentFrameIndex++; + + if (this.currentFrameIndex >= anim.frames.length) { + if (this.loop) { + this.currentFrameIndex = 0; + } else { + // Animation complete + this.currentFrameIndex = anim.frames.length - 1; + this.isPlaying = false; + if (this.onComplete) { + this.onComplete(); + } + } + } + } + + /** + * Get the current frame image + */ + getCurrentFrame(): HTMLImageElement | null { + if (!this.currentAnimation) { + return this.frames[0] ?? null; + } + + const anim = this.animations.get(this.currentAnimation); + if (!anim) { + return null; + } + + const frameIndex = anim.frames[this.currentFrameIndex]; + return this.frames[frameIndex] ?? null; + } + + /** + * Set a visual effect + */ + setEffect(effect: SpriteEffect): void { + this.effect = effect; + } + + /** + * Get current effect + */ + getEffect(): SpriteEffect { + return this.effect; + } + + /** + * Clear the effect + */ + clearEffect(): void { + this.effect = null; + } + + /** + * Check if currently playing + */ + isAnimating(): boolean { + return this.isPlaying; + } + + /** + * Get current animation name + */ + getCurrentAnimationName(): string | null { + return this.currentAnimation; + } +} diff --git a/packages/perseus/src/games/crash-course/engine/sprite-manager.ts b/packages/perseus/src/games/crash-course/engine/sprite-manager.ts new file mode 100644 index 00000000000..13d0ae405b2 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/sprite-manager.ts @@ -0,0 +1,219 @@ +/** + * Sprite Manager + * + * Manages all animated sprites in the game with batch operations. + * Central registry for sprites, batch update/draw, layer management. + */ +import {AnimatedSprite} from "./animated-sprite"; +import {SpriteAnimator} from "./sprite-animator"; + +import type {SpriteConfig} from "./types"; + +/** + * Interface for asset loading (will be implemented by AssetLoader) + */ +interface AssetProvider { + getImage(key: string): HTMLImageElement | null; +} + +/** + * Manages all sprites in the game + */ +export class SpriteManager { + private sprites: Map = new Map(); + private assetProvider: AssetProvider; + + constructor(assetProvider: AssetProvider) { + this.assetProvider = assetProvider; + } + + /** + * Create a new animated sprite from configuration + */ + async create(id: string, config: SpriteConfig): Promise { + // Load frame images + const frameImages: HTMLImageElement[] = []; + for (const frameKey of config.frames) { + const img = this.assetProvider.getImage(frameKey); + if (!img) { + throw new Error(`Image "${frameKey}" not found in assets`); + } + frameImages.push(img); + } + + // Create animator + const animator = new SpriteAnimator(); + await animator.loadFrames(frameImages); + + // Add animations + for (const [name, animConfig] of Object.entries(config.animations)) { + animator.addAnimation(name, animConfig); + } + + // Create sprite + const sprite = new AnimatedSprite( + animator, + config.position?.x ?? 0, + config.position?.y ?? 0, + config.size?.width ?? 128, + config.size?.height ?? 128, + config.layer ?? "default", + ); + + // Register sprite + this.sprites.set(id, sprite); + + return sprite; + } + + /** + * Get a sprite by ID + */ + get(id: string): AnimatedSprite | undefined { + return this.sprites.get(id); + } + + /** + * Check if a sprite exists + */ + has(id: string): boolean { + return this.sprites.has(id); + } + + /** + * Remove a sprite + */ + remove(id: string): boolean { + return this.sprites.delete(id); + } + + /** + * Get all sprites in a layer + */ + getByLayer(layer: string): AnimatedSprite[] { + return Array.from(this.sprites.values()).filter( + (sprite) => sprite.layer === layer, + ); + } + + /** + * Get all sprite IDs + */ + getAllIds(): string[] { + return Array.from(this.sprites.keys()); + } + + /** + * Get all sprites + */ + getAll(): AnimatedSprite[] { + return Array.from(this.sprites.values()); + } + + /** + * Update all sprites + */ + updateAll(deltaTime: number): void { + for (const sprite of this.sprites.values()) { + sprite.update(deltaTime); + } + } + + /** + * Draw all sprites with layer ordering + */ + drawByLayers( + ctx: CanvasRenderingContext2D, + layerOrder: string[], + ): void { + for (const layer of layerOrder) { + const sprites = this.getByLayer(layer); + for (const sprite of sprites) { + sprite.draw(ctx); + } + } + } + + /** + * Draw all sprites (no layer ordering) + */ + drawAll(ctx: CanvasRenderingContext2D): void { + for (const sprite of this.sprites.values()) { + sprite.draw(ctx); + } + } + + /** + * Clear all sprites + */ + clear(): void { + this.sprites.clear(); + } + + /** + * Get sprite count + */ + count(): number { + return this.sprites.size; + } + + /** + * Play animation on a sprite + */ + play(id: string, animationName: string, restart: boolean = false): void { + const sprite = this.get(id); + if (sprite) { + sprite.play(animationName, restart); + } + } + + /** + * Stop animation on a sprite + */ + stop(id: string): void { + const sprite = this.get(id); + if (sprite) { + sprite.stop(); + } + } + + /** + * Set position of a sprite + */ + setPosition(id: string, x: number, y: number): void { + const sprite = this.get(id); + if (sprite) { + sprite.setPosition(x, y); + } + } + + /** + * Move a sprite by delta + */ + moveBy(id: string, dx: number, dy: number): void { + const sprite = this.get(id); + if (sprite) { + sprite.moveBy(dx, dy); + } + } + + /** + * Set visibility of a sprite + */ + setVisible(id: string, visible: boolean): void { + const sprite = this.get(id); + if (sprite) { + sprite.visible = visible; + } + } + + /** + * Check collision between two sprites + */ + checkCollision(id1: string, id2: string): boolean { + const sprite1 = this.get(id1); + const sprite2 = this.get(id2); + if (!sprite1 || !sprite2) return false; + return sprite1.overlaps(sprite2); + } +} diff --git a/packages/perseus/src/games/crash-course/engine/systems/asset-loader.ts b/packages/perseus/src/games/crash-course/engine/systems/asset-loader.ts new file mode 100644 index 00000000000..f811488cf93 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/systems/asset-loader.ts @@ -0,0 +1,183 @@ +/** + * Asset Loader + * + * Loads and caches all game assets (images and audio). + * Provides progress tracking for loading screens. + */ +import type {AssetManifest} from "../types"; + +/** + * Asset loading system + */ +export class AssetLoader { + private images: Map = new Map(); + private audio: Map = new Map(); + private loaded: boolean = false; + private totalAssets: number = 0; + private loadedAssets: number = 0; + + /** + * Load all assets from a manifest + */ + async loadAssets(manifest: AssetManifest): Promise { + this.totalAssets = + Object.keys(manifest.images).length + + Object.keys(manifest.audio).length; + this.loadedAssets = 0; + this.loaded = false; + + const imagePromises: Promise[] = []; + const audioPromises: Promise[] = []; + + // Load images + for (const [key, url] of Object.entries(manifest.images)) { + imagePromises.push(this.loadImage(key, url)); + } + + // Load audio + for (const [key, url] of Object.entries(manifest.audio)) { + audioPromises.push(this.loadAudio(key, url)); + } + + // Wait for all assets to load + await Promise.all([...imagePromises, ...audioPromises]); + + this.loaded = true; + } + + /** + * Load a single image + */ + private loadImage(key: string, url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + this.images.set(key, img); + this.loadedAssets++; + resolve(); + }; + + img.onerror = () => { + console.error(`Failed to load image: ${key} from ${url}`); + this.loadedAssets++; + reject(new Error(`Failed to load image: ${key}`)); + }; + + img.src = url; + }); + } + + /** + * Load a single audio file + */ + private loadAudio(key: string, url: string): Promise { + return new Promise((resolve) => { + const audio = new Audio(); + + audio.addEventListener("canplaythrough", () => { + this.audio.set(key, audio); + this.loadedAssets++; + resolve(); + }, {once: true}); + + audio.addEventListener("error", () => { + console.error(`Failed to load audio: ${key} from ${url}`); + this.loadedAssets++; + resolve(); // Resolve anyway to not block loading + }, {once: true}); + + audio.src = url; + audio.load(); + }); + } + + /** + * Get a loaded image by key + */ + getImage(key: string): HTMLImageElement | null { + return this.images.get(key) ?? null; + } + + /** + * Get a loaded audio element by key + */ + getAudio(key: string): HTMLAudioElement | null { + return this.audio.get(key) ?? null; + } + + /** + * Check if all assets are loaded + */ + isLoaded(): boolean { + return this.loaded; + } + + /** + * Get loading progress (0-1) + */ + getProgress(): number { + if (this.totalAssets === 0) return 0; + return this.loadedAssets / this.totalAssets; + } + + /** + * Get loading progress as percentage (0-100) + */ + getProgressPercent(): number { + return Math.floor(this.getProgress() * 100); + } + + /** + * Get loaded asset count + */ + getLoadedCount(): number { + return this.loadedAssets; + } + + /** + * Get total asset count + */ + getTotalCount(): number { + return this.totalAssets; + } + + /** + * Check if a specific image is loaded + */ + hasImage(key: string): boolean { + return this.images.has(key); + } + + /** + * Check if a specific audio is loaded + */ + hasAudio(key: string): boolean { + return this.audio.has(key); + } + + /** + * Get all loaded image keys + */ + getImageKeys(): string[] { + return Array.from(this.images.keys()); + } + + /** + * Get all loaded audio keys + */ + getAudioKeys(): string[] { + return Array.from(this.audio.keys()); + } + + /** + * Clear all loaded assets + */ + clear(): void { + this.images.clear(); + this.audio.clear(); + this.loaded = false; + this.totalAssets = 0; + this.loadedAssets = 0; + } +} diff --git a/packages/perseus/src/games/crash-course/engine/systems/audio-system.ts b/packages/perseus/src/games/crash-course/engine/systems/audio-system.ts new file mode 100644 index 00000000000..5c6bee57d99 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/systems/audio-system.ts @@ -0,0 +1,198 @@ +/** + * Audio System + * + * Manages music tracks and sound effects for the game. + * Handles playback, volume control, and muting. + */ +import type {AudioTrackType} from "../types"; + +/** + * Audio management system + */ +export class AudioSystem { + private tracks: Map = new Map(); + private currentTrack: AudioTrackType | null = null; + private muted: boolean = false; + private volume: number = 0.5; // Default 50% volume + + /** + * Register an audio track + */ + registerTrack(type: AudioTrackType, audio: HTMLAudioElement): void { + audio.volume = this.volume; + this.tracks.set(type, audio); + } + + /** + * Play a specific track + */ + play(type: AudioTrackType): void { + const track = this.tracks.get(type); + if (!track) { + console.warn(`Audio track "${type}" not found`); + return; + } + + // Stop current track if different + if (this.currentTrack && this.currentTrack !== type) { + this.stopCurrent(); + } + + this.currentTrack = type; + + if (!this.muted) { + track.currentTime = 0; + track.play().catch((error) => { + console.log(`Audio play failed for "${type}":`, error); + }); + } + } + + /** + * Stop the currently playing track + */ + stopCurrent(): void { + if (!this.currentTrack) return; + + const track = this.tracks.get(this.currentTrack); + if (track) { + track.pause(); + track.currentTime = 0; + } + + this.currentTrack = null; + } + + /** + * Stop all tracks + */ + stopAll(): void { + for (const track of this.tracks.values()) { + track.pause(); + track.currentTime = 0; + } + this.currentTrack = null; + } + + /** + * Pause current track + */ + pause(): void { + if (!this.currentTrack) return; + + const track = this.tracks.get(this.currentTrack); + if (track) { + track.pause(); + } + } + + /** + * Resume current track + */ + resume(): void { + if (!this.currentTrack || this.muted) return; + + const track = this.tracks.get(this.currentTrack); + if (track) { + track.play().catch((error) => { + console.log(`Audio resume failed:`, error); + }); + } + } + + /** + * Set muted state + */ + setMuted(muted: boolean): void { + this.muted = muted; + + if (muted) { + // Mute all tracks + for (const track of this.tracks.values()) { + track.pause(); + } + } else { + // Unmute and resume current track + this.resume(); + } + } + + /** + * Get muted state + */ + isMuted(): boolean { + return this.muted; + } + + /** + * Set volume for all tracks (0-1) + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + for (const track of this.tracks.values()) { + track.volume = this.volume; + } + } + + /** + * Get current volume + */ + getVolume(): number { + return this.volume; + } + + /** + * Get current track type + */ + getCurrentTrack(): AudioTrackType | null { + return this.currentTrack; + } + + /** + * Check if a track is currently playing + */ + isPlaying(type: AudioTrackType): boolean { + const track = this.tracks.get(type); + return track ? !track.paused : false; + } + + /** + * Set up track transitions + * For example, when Tedox ends, automatically start Neon Owl + */ + setupTransition( + from: AudioTrackType, + to: AudioTrackType, + ): void { + const fromTrack = this.tracks.get(from); + if (!fromTrack) return; + + // Remove any existing ended listeners to avoid duplicates + fromTrack.removeEventListener("ended", this.handleTrackEnded); + + // Add new listener + fromTrack.addEventListener("ended", () => { + if (!this.muted) { + this.play(to); + } + }); + } + + /** + * Handle track ended event + */ + private handleTrackEnded = (): void => { + // Placeholder for track end logic + }; + + /** + * Clean up resources + */ + cleanup(): void { + this.stopAll(); + for (const track of this.tracks.values()) { + track.removeEventListener("ended", this.handleTrackEnded); + } + this.tracks.clear(); + } +} diff --git a/packages/perseus/src/games/crash-course/engine/systems/physics-system.ts b/packages/perseus/src/games/crash-course/engine/systems/physics-system.ts new file mode 100644 index 00000000000..95f072058f7 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/systems/physics-system.ts @@ -0,0 +1,228 @@ +/** + * Physics System + * + * Handles physics calculations for the game including: + * - Character jumping physics + * - Collision detection + * - Character state management (running, jumping, coolMode, impact, loss) + */ +import * as CONSTANTS from "../constants"; + +import type {CharacterState, CollisionZone, Obstacle} from "../types"; + +/** + * Character physics state + */ +export type CharacterPhysics = { + x: number; + y: number; + width: number; + height: number; + state: CharacterState; + isJumping: boolean; + jumpStartTime: number; + coolModeEndTime: number; +}; + +/** + * Physics system for game mechanics + */ +export class PhysicsSystem { + private character: CharacterPhysics; + private groundY: number; + + constructor(groundY: number = CONSTANTS.GROUND_Y) { + this.groundY = groundY; + this.character = { + x: CONSTANTS.CHARACTER_X, + y: groundY, + width: CONSTANTS.SPRITE_SIZE, + height: CONSTANTS.SPRITE_SIZE, + state: "running", + isJumping: false, + jumpStartTime: 0, + coolModeEndTime: 0, + }; + } + + /** + * Update character physics + */ + update(deltaTime: number): void { + const currentTime = Date.now(); + + // Update jumping + if (this.character.isJumping) { + this.updateJump(currentTime); + } + + // Update cool mode + if ( + this.character.state === "coolMode" && + currentTime >= this.character.coolModeEndTime + ) { + this.character.state = "running"; + } + + // Update character state based on position + if (!this.character.isJumping && this.character.state === "running") { + this.character.y = this.groundY; + } + } + + /** + * Update jump physics + */ + private updateJump(currentTime: number): void { + const elapsed = currentTime - this.character.jumpStartTime; + + if (elapsed >= CONSTANTS.JUMP_DURATION) { + // Jump complete + this.character.isJumping = false; + this.character.y = this.groundY; + } else { + // Calculate jump arc using sine wave + const progress = elapsed / CONSTANTS.JUMP_DURATION; + const jumpArc = Math.sin(progress * Math.PI); // 0 -> 1 -> 0 + this.character.y = this.groundY - jumpArc * CONSTANTS.JUMP_HEIGHT; + } + } + + /** + * Start a jump + */ + jump(): boolean { + if (this.character.isJumping) { + return false; // Already jumping + } + + this.character.isJumping = true; + this.character.jumpStartTime = Date.now(); + return true; + } + + /** + * Trigger cool mode after correct answer + */ + triggerCoolMode(): void { + this.character.state = "coolMode"; + this.character.coolModeEndTime = + Date.now() + CONSTANTS.COOL_MODE_DURATION; + } + + /** + * Trigger impact state + */ + triggerImpact(): void { + this.character.state = "impact"; + } + + /** + * Trigger loss state (alien abduction) + */ + triggerLoss(): void { + this.character.state = "loss"; + } + + /** + * Reset character to running state + */ + resetToRunning(): void { + this.character.state = "running"; + this.character.isJumping = false; + this.character.y = this.groundY; + this.character.coolModeEndTime = 0; + } + + /** + * Get character state + */ + getCharacterState(): CharacterPhysics { + return {...this.character}; + } + + /** + * Get character collision box + */ + getCharacterCollisionBox(): CollisionZone { + return { + x: this.character.x, + y: this.character.y, + width: this.character.width, + height: this.character.height, + }; + } + + /** + * Check collision between character and obstacle + */ + checkCollision(obstacle: Obstacle): boolean { + const char = this.character; + const obs = obstacle; + + return ( + char.x < obs.x + obs.width && + char.x + char.width > obs.x && + char.y < obs.y + obs.height && + char.y + char.height > obs.y + ); + } + + /** + * Check if character is in collision zone with obstacle + * Collision zone is slightly ahead of character for better timing + */ + checkCollisionZone(obstacle: Obstacle): boolean { + // Check if obstacle is in the collision zone + return ( + obstacle.x < + CONSTANTS.COLLISION_ZONE_X + CONSTANTS.COLLISION_ZONE_WIDTH && + obstacle.x + obstacle.width > CONSTANTS.COLLISION_ZONE_X + ); + } + + /** + * Check if character successfully jumped over an obstacle + */ + hasJumpedOver(obstacle: Obstacle): boolean { + // Character is past the obstacle and was jumping + return this.character.x > obstacle.x + obstacle.width; + } + + /** + * Reset physics system + */ + reset(): void { + this.character = { + x: CONSTANTS.CHARACTER_X, + y: this.groundY, + width: CONSTANTS.SPRITE_SIZE, + height: CONSTANTS.SPRITE_SIZE, + state: "running", + isJumping: false, + jumpStartTime: 0, + coolModeEndTime: 0, + }; + } + + /** + * Get current character Y position + */ + getCharacterY(): number { + return this.character.y; + } + + /** + * Is character jumping? + */ + isJumping(): boolean { + return this.character.isJumping; + } + + /** + * Get character state + */ + getState(): CharacterState { + return this.character.state; + } +} diff --git a/packages/perseus/src/games/crash-course/engine/systems/render-system.ts b/packages/perseus/src/games/crash-course/engine/systems/render-system.ts new file mode 100644 index 00000000000..872c7f89dc7 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/systems/render-system.ts @@ -0,0 +1,276 @@ +/** + * Render System + * + * Canvas drawing utilities and helpers for rendering game graphics. + * Provides reusable drawing operations separate from game logic. + */ + +/** + * Canvas rendering system with helper methods + */ +export class RenderSystem { + private ctx: CanvasRenderingContext2D; + private canvas: HTMLCanvasElement; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Failed to get 2D context from canvas"); + } + this.ctx = ctx; + } + + /** + * Clear the entire canvas + */ + clear(): void { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + + /** + * Clear a specific area + */ + clearRect(x: number, y: number, width: number, height: number): void { + this.ctx.clearRect(x, y, width, height); + } + + /** + * Draw an image at specified position + */ + drawImage( + image: HTMLImageElement, + x: number, + y: number, + width?: number, + height?: number, + ): void { + if (width !== undefined && height !== undefined) { + this.ctx.drawImage(image, x, y, width, height); + } else { + this.ctx.drawImage(image, x, y); + } + } + + /** + * Draw a sprite with effects + */ + drawSprite( + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, + effects?: { + tint?: string; + shake?: {x: number; y: number}; + opacity?: number; + }, + ): void { + this.ctx.save(); + + // Apply opacity + if (effects?.opacity !== undefined) { + this.ctx.globalAlpha = effects.opacity; + } + + // Apply shake + let drawX = x; + let drawY = y; + if (effects?.shake) { + drawX += effects.shake.x; + drawY += effects.shake.y; + } + + // Draw image + this.ctx.drawImage(image, drawX, drawY, width, height); + + // Apply tint (using a colored rectangle with blend mode) + if (effects?.tint) { + this.ctx.fillStyle = effects.tint; + this.ctx.globalCompositeOperation = "multiply"; + this.ctx.fillRect(drawX, drawY, width, height); + } + + this.ctx.restore(); + } + + /** + * Draw parallax background layer + */ + drawParallaxLayer( + image: HTMLImageElement, + offset: number, + y: number = 0, + height?: number, + ): void { + const imageWidth = image.width; + const drawHeight = height ?? this.canvas.height; + + // Wrap offset to image width + const wrappedOffset = offset % imageWidth; + + // Draw first image + this.ctx.drawImage( + image, + -wrappedOffset, + y, + imageWidth, + drawHeight, + ); + + // Draw second image to fill gap + if (wrappedOffset > 0) { + this.ctx.drawImage( + image, + imageWidth - wrappedOffset, + y, + imageWidth, + drawHeight, + ); + } + } + + /** + * Draw text + */ + drawText( + text: string, + x: number, + y: number, + options?: { + font?: string; + color?: string; + align?: CanvasTextAlign; + baseline?: CanvasTextBaseline; + stroke?: boolean; + strokeColor?: string; + strokeWidth?: number; + }, + ): void { + this.ctx.save(); + + // Set text properties + this.ctx.font = options?.font ?? "16px Arial"; + this.ctx.fillStyle = options?.color ?? "#FFFFFF"; + this.ctx.textAlign = options?.align ?? "left"; + this.ctx.textBaseline = options?.baseline ?? "top"; + + // Draw text + if (options?.stroke) { + this.ctx.strokeStyle = options.strokeColor ?? "#000000"; + this.ctx.lineWidth = options.strokeWidth ?? 2; + this.ctx.strokeText(text, x, y); + } + this.ctx.fillText(text, x, y); + + this.ctx.restore(); + } + + /** + * Draw a filled rectangle + */ + drawRect( + x: number, + y: number, + width: number, + height: number, + color: string, + ): void { + this.ctx.fillStyle = color; + this.ctx.fillRect(x, y, width, height); + } + + /** + * Draw a stroked rectangle + */ + strokeRect( + x: number, + y: number, + width: number, + height: number, + color: string, + lineWidth: number = 1, + ): void { + this.ctx.strokeStyle = color; + this.ctx.lineWidth = lineWidth; + this.ctx.strokeRect(x, y, width, height); + } + + /** + * Draw a filled circle + */ + drawCircle( + x: number, + y: number, + radius: number, + color: string, + ): void { + this.ctx.fillStyle = color; + this.ctx.beginPath(); + this.ctx.arc(x, y, radius, 0, Math.PI * 2); + this.ctx.fill(); + } + + /** + * Draw a stroked circle + */ + strokeCircle( + x: number, + y: number, + radius: number, + color: string, + lineWidth: number = 1, + ): void { + this.ctx.strokeStyle = color; + this.ctx.lineWidth = lineWidth; + this.ctx.beginPath(); + this.ctx.arc(x, y, radius, 0, Math.PI * 2); + this.ctx.stroke(); + } + + /** + * Set global opacity for subsequent draws + */ + setGlobalAlpha(alpha: number): void { + this.ctx.globalAlpha = alpha; + } + + /** + * Reset global alpha to 1 + */ + resetGlobalAlpha(): void { + this.ctx.globalAlpha = 1; + } + + /** + * Save canvas state + */ + save(): void { + this.ctx.save(); + } + + /** + * Restore canvas state + */ + restore(): void { + this.ctx.restore(); + } + + /** + * Get the canvas context (for advanced operations) + */ + getContext(): CanvasRenderingContext2D { + return this.ctx; + } + + /** + * Get canvas dimensions + */ + getDimensions(): {width: number; height: number} { + return { + width: this.canvas.width, + height: this.canvas.height, + }; + } +} diff --git a/packages/perseus/src/games/crash-course/engine/systems/system-interfaces.ts b/packages/perseus/src/games/crash-course/engine/systems/system-interfaces.ts new file mode 100644 index 00000000000..cce8d510e03 --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/systems/system-interfaces.ts @@ -0,0 +1,148 @@ +/** + * System interfaces for Crash Course game engine + * + * These interfaces define the API for reusable game systems. + */ +import type {AudioTrackType, AssetManifest} from "../types"; + +/** + * Asset loading system + */ +export interface AssetLoader { + /** + * Load all assets defined in the manifest + */ + loadAssets(manifest: AssetManifest): Promise; + + /** + * Get a loaded image by key + */ + getImage(key: string): HTMLImageElement | null; + + /** + * Get a loaded audio element by key + */ + getAudio(key: string): HTMLAudioElement | null; + + /** + * Check if all assets are loaded + */ + isLoaded(): boolean; + + /** + * Get loading progress (0-1) + */ + getProgress(): number; +} + +/** + * Audio management system + */ +export interface AudioSystem { + /** + * Register an audio track + */ + registerTrack(type: AudioTrackType, audio: HTMLAudioElement): void; + + /** + * Play a specific track type + */ + play(type: AudioTrackType): void; + + /** + * Stop all currently playing audio + */ + stopAll(): void; + + /** + * Mute/unmute all audio + */ + setMuted(muted: boolean): void; + + /** + * Get current mute state + */ + isMuted(): boolean; + + /** + * Set volume for all tracks (0-1) + */ + setVolume(volume: number): void; +} + +/** + * Canvas rendering system + */ +export interface RenderSystem { + /** + * Clear the canvas + */ + clear(): void; + + /** + * Draw an image at specified position + */ + drawImage( + image: HTMLImageElement, + x: number, + y: number, + width?: number, + height?: number, + ): void; + + /** + * Draw a sprite with effects (tint, shake, opacity) + */ + drawSprite( + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, + effects?: { + tint?: string; + shake?: {x: number; y: number}; + opacity?: number; + }, + ): void; + + /** + * Draw text + */ + drawText( + text: string, + x: number, + y: number, + options?: { + font?: string; + color?: string; + align?: CanvasTextAlign; + }, + ): void; + + /** + * Draw a rectangle + */ + drawRect( + x: number, + y: number, + width: number, + height: number, + color: string, + ): void; + + /** + * Set global opacity for subsequent draws + */ + setGlobalAlpha(alpha: number): void; + + /** + * Save canvas state + */ + save(): void; + + /** + * Restore canvas state + */ + restore(): void; +} diff --git a/packages/perseus/src/games/crash-course/engine/types.ts b/packages/perseus/src/games/crash-course/engine/types.ts new file mode 100644 index 00000000000..8d5c8a5b58e --- /dev/null +++ b/packages/perseus/src/games/crash-course/engine/types.ts @@ -0,0 +1,131 @@ +/** + * Type definitions for Crash Course game engine + */ +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Game state types + */ +export type GameState = + | "start" + | "story" + | "playing" + | "carBonus" + | "gameover" + | "victory"; + +/** + * Character animation states + */ +export type CharacterState = "running" | "coolMode" | "impact" | "loss"; + +/** + * UI state that React needs for rendering + */ +export type GameUIState = { + gameState: GameState; + storyPage: number; + score: number; + lives: number; + gameTime: string; + currentQuestion: PerseusItem | null; + showBenevolence: boolean; + isMuted: boolean; +}; + +/** + * Obstacle in the game + */ +export type Obstacle = { + id: string; + x: number; + y: number; + width: number; + height: number; + question: PerseusItem; + answered: boolean; + correct: boolean; + jumped?: boolean; + racing?: boolean; + racingStartTime?: number; +}; + +/** + * Configuration for engine initialization + */ +export type EngineConfig = { + canvas: HTMLCanvasElement; + onUIUpdate: (state: GameUIState) => void; +}; + +/** + * Sprite animation configuration + */ +export type SpriteAnimationConfig = { + frames: number[]; + fps?: number; + loop?: boolean; + onComplete?: () => void; +}; + +/** + * Sprite effect types + */ +export type SpriteEffect = + | {type: "shake"; intensity: number; duration: number} + | {type: "tint"; color: string} + | {type: "opacity"; value: number} + | null; + +/** + * Sprite configuration for creating animated sprites + */ +export type SpriteConfig = { + frames: string[]; // URLs or keys of loaded images + animations: Record; + position?: {x: number; y: number}; + size?: {width: number; height: number}; + layer?: string; // For z-ordering +}; + +/** + * Asset manifest - defines all assets to load + */ +export type AssetManifest = { + images: Record; // key -> URL + audio: Record; // key -> URL +}; + +/** + * Collision zone definition + */ +export type CollisionZone = { + x: number; + y: number; + width: number; + height: number; +}; + +/** + * Audio track types + */ +export type AudioTrackType = "menu" | "gameplay" | "extended" | "gameover"; + +/** + * Game constants + */ +export const GAME_CONSTANTS = { + CANVAS_WIDTH: 800, + CANVAS_HEIGHT: 600, + GROUND_Y: 450, + SCROLL_SPEED: 2, + CHARACTER_X: 100, + SPRITE_SIZE: 128, + JUMP_HEIGHT: 140, + JUMP_DURATION: 1000, + OBSTACLE_SPAWN_INTERVAL: 5000, + COLLISION_ZONE_X: 248, // CHARACTER_X + CHARACTER_WIDTH + 20 + COOL_MODE_DURATION: 2000, + LAMP_SPACING: 500, + GAME_DURATION: 300000, // 5 minutes +} as const; diff --git a/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_CONCEPT.md b/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_CONCEPT.md new file mode 100644 index 00000000000..6159722df7e --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_CONCEPT.md @@ -0,0 +1,229 @@ +# Concept: Crash Course Game Reorganization + +## What We're Building + +A complete refactoring and reorganization of the "Crash Course" (formerly "math-blaster") game from a rapid prototype into a maintainable, well-structured proof of concept. The game is an endless runner that integrates Perseus widgets to create an educational gaming experience where players answer math questions to jump over obstacles. + +**Key Change**: Moving from a React-heavy implementation to a **custom game engine architecture** that separates game logic from UI rendering, making the code more maintainable and setting a pattern for future educational games. + +## Why + +The current implementation exists as a 1673-line monolithic React component in the `__docs__` directory with: +- All game logic, rendering, and UI in a single file +- 40+ asset files scattered in the root `__docs__` folder +- Inconsistent naming (still using "math-blaster" in many places) +- Complex state management with 30+ state variables and parallel ref tracking +- React + requestAnimationFrame hooks causing complex dependency management +- No documentation or code comments +- Difficult to understand, modify, or maintain + +This refactoring will transform it into a clean, maintainable proof of concept that demonstrates best practices for building Perseus-based educational games while establishing a reusable pattern for future games. + +## Key Components + +### 1. **Game Engine Architecture (NEW)** +- **Custom TypeScript game engine** - Pure TypeScript class, no React dependencies +- **60fps game loop** - Independent of React rendering +- **System-based organization** - Render, Audio, Asset loading systems +- **Clear API** - Public methods for React to call (start, stop, answerQuestion) +- **Testable** - Can unit test game logic without React + +**Architecture:** +``` +CrashCourseEngine (pure TypeScript) +├── Game Loop (60fps) +│ ├── update() - Physics, collision, spawning +│ └── render() - Canvas drawing +├── Systems +│ ├── RenderSystem - Canvas drawing helpers +│ ├── AudioSystem - Music/sound management +│ └── AssetLoader - Image/audio loading +└── Perseus Integration + └── Question presentation/answer handling + +React Component (thin wrapper) +├── Canvas element +├── UI Overlays (HUD, Question, Screens) +└── Communicates with engine via callbacks +``` + +### 2. **New Directory Structure** +- **Location**: `packages/perseus/src/games/crash-course/` +- **Assets**: Organized into subdirectories by type + - `assets/sprites/` - Character animations, aliens, cars + - `assets/backgrounds/` - Sky, city layers, ground elements + - `assets/ui/` - Buttons, icons, screens + - `assets/audio/` - Music and sound effects + - `assets/story/` - Story page images +- **Engine**: Game logic in pure TypeScript + - `engine/CrashCourseEngine.ts` - Main game engine + - `engine/systems/` - Reusable systems (render, audio, assets) + - `engine/types.ts` - Game-specific types +- **Components**: React UI only + - `components/` - Screen components, HUD, overlays + - No game logic in components + +### 3. **Component Breakdown** +React handles UI only, game engine handles logic: +- `CrashCourseGame.tsx` - Main component, thin wrapper (< 150 lines) +- `engine/CrashCourseEngine.ts` - Game engine class (300-400 lines) +- `engine/systems/RenderSystem.ts` - Canvas drawing utilities +- `engine/systems/AudioSystem.ts` - Audio management +- `engine/systems/AssetLoader.ts` - Asset loading +- `components/QuestionOverlay.tsx` - Perseus question rendering +- `components/HUD.tsx` - Score, timer, lives display +- `components/StartScreen.tsx` - Title screen +- `components/StoryScreen.tsx` - Story page viewer +- `components/VictoryScreen.tsx` - Victory end screen +- `components/GameOverScreen.tsx` - Game over end screen +- `components/CarBonusScene.tsx` - Existing, just move it +- `types.ts` - Shared TypeScript types + +### 4. **Asset Organization** +Move and rename all 40+ assets from `__docs__` to proper subdirectories with consistent naming: +- Use kebab-case for all filenames +- Group by functionality (character, environment, UI, audio) +- Update all import paths throughout the codebase + +### 5. **Documentation** +- `README.md` - Overview, how to run, architecture explanation +- Inline code comments explaining complex logic +- JSDoc comments for exported functions and components +- Architecture diagram (ASCII art in README) +- System documentation for each major component + +### 6. **Code Quality Improvements** +- **Separation of Concerns**: Game logic vs. UI completely separated +- **Testability**: Game engine is pure TypeScript, easily testable +- **Performance**: Game runs at 60fps, UI updates throttled to 10-15fps +- **Maintainability**: Clear responsibilities, focused modules +- **Extensibility**: Systems can be reused for future games +- **Type Safety**: Comprehensive TypeScript types +- **Error Handling**: Proper error boundaries and fallbacks + +## How It Works (High Level) + +### User Flow +1. **Start Screen** → Player sees title, clicks "Start" +2. **Story Sequence** → 7-page narrative introduction +3. **Gameplay** → Endless runner with Perseus questions, 5-minute timer +4. **End State** → Either victory (survived 5 min) or game over (ran out of lives) + +### Technical Architecture +``` +┌─────────────────────────────────────────────────────────────┐ +│ CrashCourseGame │ +│ (React Component) │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ (Game engine draws here) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ UI Overlays (React): │ │ +│ │ - HUD (score, time, lives) │ │ +│ │ - QuestionOverlay (Perseus widgets) │ │ +│ │ - Screens (start, story, victory, game over) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ▲ UI State Updates (throttled 10fps) │ +│ │ │ +└──────────────┼─────────────────────────────────────────────┘ + │ + │ onStateChange callback + │ +┌──────────────┴─────────────────────────────────────────────┐ +│ CrashCourseEngine │ +│ (Pure TypeScript) │ +│ │ +│ Game Loop (60fps): │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ update() │ → │ render() │ → requestAnimationFrame +│ │ │ │ │ │ +│ │ - Physics │ │ - Canvas │ │ +│ │ - Collision │ │ drawing │ │ +│ │ - Spawning │ │ - Sprites │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +│ Systems: │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ RenderSystem │ │ AudioSystem │ │ AssetLoader │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Data Flow +1. **Engine runs independently** - 60fps game loop +2. **Engine notifies React** - Throttled UI state updates (10-15fps) +3. **React renders UI** - Overlays, HUD, Perseus questions +4. **React calls engine** - Player actions (answerQuestion, start, stop) + +## Technical Considerations + +### Game Engine Benefits +- **Performance**: Game loop not blocked by React re-renders +- **Testability**: Pure TypeScript, can unit test without mounting React +- **Maintainability**: Clear separation between game logic and UI +- **Reusability**: Systems can be shared with future games +- **Debugging**: Can run engine headless for testing + +### Perseus Integration +- React component handles ServerItemRenderer (Perseus widgets) +- Engine exposes `getCurrentQuestion()` for React to render +- React calls `engine.answerQuestion(result)` with scoring result +- Clean interface between game and Perseus + +### Browser Compatibility +- Requires HTML5 Canvas support +- Audio autoplay requires user interaction +- Modern JavaScript features (ES6+) +- TypeScript compiled to ES2019+ + +### Performance +- Game loop: 60fps (requestAnimationFrame) +- UI updates: 10-15fps (throttled) +- Canvas redraws: Every frame +- React re-renders: Only when UI state changes + +### Naming Consistency +- Transition all references from "math-blaster" to "crash-course" +- Update Storybook title from "Math Blaster Game" to "Crash Course" +- Consistent file naming convention throughout + +## Success Criteria + +- [ ] Game moved to `packages/perseus/src/games/crash-course/` +- [ ] All assets organized in appropriate subdirectories +- [ ] Game engine architecture implemented (pure TypeScript) +- [ ] Systems extracted (Render, Audio, AssetLoader) +- [ ] React component is thin wrapper (< 200 lines) +- [ ] All naming updated from "math-blaster" to "crash-course" +- [ ] README.md with architecture documentation +- [ ] Unit tests for game engine logic +- [ ] Code comments explaining complex logic +- [ ] No functionality lost - game plays identically to before +- [ ] Storybook story works and appears under "Games/Crash Course" +- [ ] All TypeScript types properly defined +- [ ] Code passes existing linting and type checking +- [ ] Performance maintained or improved (60fps) +- [ ] Game engine is testable without React +- [ ] Clear API for future games to follow same pattern + +## Future Enhancements + +### For This Game (Out of Scope for Refactor) +- Additional question types or difficulty levels +- Sound effects (only music currently) +- Mobile/touch controls +- Save/load game state +- Leaderboards + +### For Future Games (Extensibility) +- **Reusable Systems**: RenderSystem, AudioSystem, AssetLoader can be shared +- **Base Engine**: Could extract common game loop logic to BaseGameEngine +- **Perseus Integration**: QuestionOverlay component could work for other games +- **Shared Types**: Perseus game engine interface for consistency +- **Testing Utilities**: Test harness for game engines + +This refactoring establishes a **pattern for educational games at Khan Academy** while keeping the implementation pragmatic and focused on the current needs. diff --git a/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_IMPLEMENTATION_PLAN.md b/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000000..7c7e389ee5f --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/CRASH_COURSE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,298 @@ +# Implementation Plan: Crash Course Game Reorganization + +Based on: [CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md) + +## Overview + +This refactoring transforms the Crash Course game from a 1673-line monolithic React component into a well-organized, maintainable game with a **custom game engine architecture**. The key innovation is separating game logic (pure TypeScript) from UI rendering (React), which solves the React + requestAnimationFrame complexity and establishes a reusable pattern for future educational games. + +**Strategy**: Build game engine first, then wrap with React UI + +## Key Architectural Decision + +**Game Engine Pattern**: Instead of refactoring React hooks and state management, we're extracting all game logic into a pure TypeScript game engine class. This: +- Eliminates React + game loop complexity +- Makes game logic testable without React +- Runs at 60fps independently +- Throttles UI updates to 10-15fps +- Sets pattern for future games + +## Phases + +### Phase 0: Testing Foundation & Performance Baselines +**Goal**: Establish testing infrastructure and capture current performance metrics before making any changes + +**Estimated Effort**: 2-3 hours + +This phase ensures we can verify the refactored game works identically and performs as well (or better) than the original. Critical for catching regressions. + +**Details**: See `PHASE_0_testing_foundation.md` + +--- + +### Phase 1: Setup, Assets & Engine Architecture +**Goal**: Create directory structure, organize assets, and design the game engine API + +**Estimated Effort**: 2-3 hours + +Sets up the foundation by creating folders, moving assets, and defining the interfaces between the game engine and React. No behavior changes yet - just organization and API design. + +**Details**: See `PHASE_1_setup_assets_architecture.md` + +--- + +### Phase 2: Build Game Engine +**Goal**: Extract all game logic into the CrashCourseEngine class with supporting systems + +**Estimated Effort**: 5-7 hours + +The core refactoring work. Move all game loop logic, physics, collision detection, and canvas rendering from the React component into a pure TypeScript game engine. This is the most complex phase but well-defined by the API designed in Phase 1. + +**Details**: See `PHASE_2_build_engine.md` + +--- + +### Phase 3: Extract UI Components & Wire Up +**Goal**: Create React components for UI overlays and connect them to the game engine + +**Estimated Effort**: 2-3 hours + +Now that the engine is working, build the React wrapper. Extract UI components (screens, HUD, question overlay) and connect them to the game engine via callbacks. React becomes a thin UI layer. + +**Details**: See `PHASE_3_ui_components.md` + +--- + +### Phase 4: Documentation & Polish +**Goal**: Add comprehensive documentation, verify everything works, and finalize + +**Estimated Effort**: 2-3 hours + +Write documentation explaining the architecture, add code comments, verify tests pass, ensure performance is maintained, and clean up any rough edges. + +**Details**: See `PHASE_4_documentation_polish.md` + +--- + +## Dependencies + +**Sequential Phases**: +- Phase 0 must be first (establishes testing baseline) +- Phase 1 must be before Phase 2 (creates structure and API) +- Phase 2 must be before Phase 3 (engine must exist before UI connects to it) +- Phase 4 is last (documents completed work) + +**Clear Boundaries**: +- Each phase has a clear completion point +- Each phase leaves the code in a working state +- Can pause between phases for review +- Can rollback to previous phase if needed + +## Testing Strategy + +### After Phase 0: +- Baseline tests exist +- Performance metrics captured +- Can compare against these after each phase + +### After Phase 1: +- `pnpm tsc` - Type checking passes +- `pnpm lint` - Linting passes +- Assets are accessible at new paths +- Original game still works + +### After Phase 2: +- Unit tests for engine logic pass +- Engine can run headless (without React) +- Performance benchmarks meet or exceed baseline +- Game plays identically in Storybook + +### After Phase 3: +- Full integration tests pass +- React components render correctly +- Perseus widget integration works +- All screens and transitions work +- Audio plays correctly + +### After Phase 4: +- Documentation is complete and accurate +- All tests pass +- Code is clean and commented +- Ready for production use + +## Rollback Strategy + +### Git Strategy: +- Create feature branch: `refactor/crash-course-engine` +- Commit after each major task (not just each phase) +- Tag after each phase: `phase-0-complete`, `phase-1-complete`, etc. +- Can revert to any tag if issues arise + +### Mid-Phase Rollback: +- Each task within a phase should be atomic +- Commit after each task with clear message +- Can rollback individual tasks without losing phase progress + +### Feature Flag (Optional): +```typescript +// Can keep both implementations temporarily +const USE_ENGINE = process.env.USE_ENGINE || false; + +export default USE_ENGINE ? CrashCourseGameEngine : CrashCourseGameLegacy; +``` + +## Performance Budget + +**Frame Rate**: +- Target: 60fps game rendering +- Minimum: 55fps sustained +- UI updates: 10-15fps (acceptable) + +**Load Time**: +- Asset loading: < 3 seconds +- Game initialization: < 500ms + +**Memory**: +- No memory leaks during gameplay +- Memory stable over 5-minute session + +**Build Size**: +- Code size: < 100KB (minified) +- Assets: Current size acceptable (~10MB total) + +## Risk Mitigation + +### High-Risk Areas & Mitigations: + +**1. Game Loop Extraction (Phase 2)** +- **Risk**: Complex logic, easy to introduce bugs +- **Mitigation**: + - Thorough unit tests before refactoring + - Extract incrementally (move one system at a time) + - Test at each step + - Use baseline tests to catch regressions + +**2. Perseus Integration (Phase 3)** +- **Risk**: Question rendering might break +- **Mitigation**: + - Keep Perseus integration in React (don't move to engine) + - Test question answering thoroughly + - Use existing Perseus test data + +**3. Performance Regression (All Phases)** +- **Risk**: Changes might slow down the game +- **Mitigation**: + - Capture baselines in Phase 0 + - Profile after each phase + - Use throttling for UI updates + - Keep game loop pure (no heavy operations) + +**4. Asset Loading (Phase 1)** +- **Risk**: Assets might not load after moving +- **Mitigation**: + - Test asset loading separately + - Use AssetLoader system with error handling + - Verify build output includes assets + - Test in production build mode + +## Timeline Estimate + +- **Phase 0**: 2-3 hours (testing foundation) +- **Phase 1**: 2-3 hours (setup and architecture) +- **Phase 2**: 5-7 hours (build engine - most complex) +- **Phase 3**: 2-3 hours (UI components) +- **Phase 4**: 2-3 hours (documentation) + +**Total**: 13-19 hours of focused work + +**Realistic Schedule**: +- Week 1: Phase 0 + Phase 1 +- Week 2: Phase 2 (engine extraction) +- Week 3: Phase 3 + Phase 4 + +Can be compressed or extended based on available time. Natural break points after each phase. + +## Benefits of This Approach + +### Compared to Original Plan: + +**Simpler**: +- ❌ No complex hook dependency management +- ❌ No dual state/ref patterns +- ❌ No React + requestAnimationFrame conflicts +- ✅ Game logic is plain TypeScript +- ✅ Clear separation of concerns + +**More Testable**: +- ✅ Can unit test engine without React +- ✅ Can run engine headless +- ✅ Pure functions easy to test +- ✅ Clear interfaces to mock + +**Better Performance**: +- ✅ 60fps game loop (not affected by React) +- ✅ UI updates throttled (fewer re-renders) +- ✅ No unnecessary React overhead in game logic + +**Future-Proof**: +- ✅ Pattern for future games +- ✅ Systems can be shared across games +- ✅ Clear API for game engines +- ✅ Perseus integration pattern established + +## Success Criteria + +### Phase 0 Complete: +- [ ] Test infrastructure set up +- [ ] Baseline tests written and passing +- [ ] Performance metrics captured +- [ ] Ready to begin refactoring + +### Phase 1 Complete: +- [ ] New directory structure exists +- [ ] All assets moved and organized +- [ ] Engine API designed and typed +- [ ] Original game still works + +### Phase 2 Complete: +- [ ] Game engine class implemented +- [ ] All systems extracted (Render, Audio, AssetLoader) +- [ ] Game runs at 60fps +- [ ] Unit tests pass +- [ ] Engine can run headless + +### Phase 3 Complete: +- [ ] React component is thin wrapper +- [ ] UI components extracted +- [ ] Perseus integration works +- [ ] All screens functional +- [ ] Game plays identically to original + +### Phase 4 Complete: +- [ ] README and documentation complete +- [ ] Code comments added +- [ ] All tests passing +- [ ] Performance meets budget +- [ ] Ready for production + +### Overall Success: +- [ ] Game moved to `games/crash-course/` +- [ ] Clean architecture (engine + UI) +- [ ] All tests passing +- [ ] Performance maintained or improved +- [ ] Documentation comprehensive +- [ ] Pattern established for future games +- [ ] No functionality lost +- [ ] Code is maintainable + +## Next Steps + +After completing this plan: + +1. **Review with team** - Get feedback on architecture +2. **Start with Phase 0** - Establish testing foundation +3. **Work incrementally** - Complete each phase fully before moving on +4. **Test thoroughly** - Use baseline tests to catch regressions +5. **Document as you go** - Don't leave documentation for the end + +The game engine architecture will make this refactoring cleaner and set a great precedent for future educational games at Khan Academy! diff --git a/packages/perseus/src/games/crash-course/plans/ORIGINAL_BEHAVIOR.md b/packages/perseus/src/games/crash-course/plans/ORIGINAL_BEHAVIOR.md new file mode 100644 index 00000000000..010c3db9465 --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/ORIGINAL_BEHAVIOR.md @@ -0,0 +1,360 @@ +# Crash Course - Original Game Behavior + +**Date captured**: 2025-01-07 +**Purpose**: Document current game behavior before refactoring + +## Game Overview + +Crash Course is an educational endless runner where the player answers math questions while avoiding car obstacles. The game runs for 5 minutes (game time: 11:55pm to midnight), with increasing difficulty. + +## Game Constants + +``` +Canvas: 800x600px +Ground level: 450px from top +Scroll speed: 2px/frame (base) +Character position: X=100 +Jump height: 140px +Jump duration: 1000ms +Obstacle spawn interval: 5000ms +Collision zone: X=248 (character + width + 20) +Cool mode duration: 2000ms after correct answer +Lamp spacing: 500px +Game duration: 5 minutes (300,000ms) +``` + +## Game States + +### 1. Start Screen (`"start"`) +- **Display**: Title image, start button, mute/unmute toggle +- **Music**: Alex Bouncy Mix (looped) +- **Actions**: + - Click start → transition to story + - Mute toggle works + +### 2. Story Screen (`"story"`) +- **Display**: 7 story pages with next button +- **Music**: Alex Bouncy Mix continues +- **Actions**: + - Click next → advance to next page + - Page 7 next → transition to playing state + - Can skip through quickly + +### 3. Playing State (`"playing"`) +- **Display**: Running character, scrolling background, obstacles, HUD +- **Music**: Tedox (plays once) → Neon Owl (loops) +- **Mechanics**: Core gameplay (see below) + +### 4. Car Bonus (`"carBonus"`) +- **Trigger**: Answer the final question (special car obstacle) +- **Display**: Bonus screen showing racing car animation +- **Duration**: 3 seconds +- **Actions**: Auto-transition to victory + +### 5. Victory Screen (`"victory"`) +- **Trigger**: Survive 5 minutes without losing all lives +- **Display**: Victory image, final score, "Play Again" button +- **Music**: Victory sound +- **Actions**: Click play again → restart game + +### 6. Game Over Screen (`"gameover"`) +- **Trigger**: Lose all 3 lives +- **Display**: Game over image, final score, "Play Again" button +- **Music**: Game Over II +- **Actions**: Click play again → restart game + +## Core Gameplay Mechanics + +### Character States + +1. **Running** (`"running"`) + - Default state + - 6-frame running animation (8fps) + - Character at ground level (Y=450) + +2. **Cool Mode** (`"coolMode"`) + - Activated after correct answer + - Lasts 2000ms + - Purple tinted running animation + - Same 6-frame cycle + +3. **Impact** (`"impact"`) + - Shows when wrong answer given + - Impact sprite displayed + - Brief state before returning to running + +4. **Loss** (`"loss"`) + - Shows when life is lost + - Impact sprite displayed + - Triggers alien abduction + +### Obstacle System + +**Obstacle Properties:** +- Spawn at X=800 (off-screen right) +- Width/Height: 154px (car sprite at 0.6 scale) +- Contains Perseus question (one of 4 types) +- Moves left at scroll speed + +**Obstacle Lifecycle:** +1. Spawns every 5000ms at X=800 +2. Scrolls left with background +3. When enters collision zone (X < 248): + - If not answered: Present question to player + - If answered correctly: Disappears, score++, cool mode activated + - If answered incorrectly: Disappears, lose life, alien abduction + - If unanswered when passed: Nothing happens (missed) +4. Removed when off-screen (X < -200) + +**Final Obstacle:** +- Special car obstacle with jump mechanic +- Answering triggers car bonus scene +- Leads to victory + +### Question Types + +1. **Addition**: $a + b$ (numeric input, 1-50 range) +2. **Subtraction**: $a - b$ (numeric input, 10-50 range, positive results) +3. **Multiplication**: $a \times b$ (numeric input, 2-12 range) +4. **Division**: $a \div b$ (radio widget, 4 choices, 2-12 range, exact division) + +### Life System + +- Start with 3 lives +- Lose 1 life for incorrect answer +- No way to gain lives +- 0 lives = game over + +### Scoring System + +- Start at 0 points +- +1 point per correct answer +- No points deducted for wrong answers +- Final score displayed at end + +### Timer System + +- Game time: 11:55:00 → 12:00:00 +- Real time: 5 minutes +- Display format: "HH:MM:SS" +- Timer counts up toward midnight +- Reaching midnight = victory (if still alive) + +### Alien Abduction System + +**Triggered when**: Player loses a life (wrong answer) + +**Sequence:** +1. Alien appears at top of screen (300x300px sprite) +2. Floats with gentle wave motion +3. Blinks periodically (2-5 second intervals) +4. Beam animation appears below alien +5. Character is "abducted" (pulled up by beam) +6. Alien and character fly off screen to the right +7. **"BENEVOLENCE" message** appears briefly +8. Alien returns from right, drops character +9. Gameplay resumes + +**Alien States:** +- Idle floating (wave motion) +- Abducting (beam active, character pulled up) +- Flying away (with character) +- Returning (flying back) +- Dropping character + +### Visual Effects + +**Parallax Scrolling (5 layers):** +1. Sky (static) +2. City far (0.3x speed) +3. City semi-far (0.5x speed) +4. City semi-close (0.8x speed) +5. City close (1.0x speed) +6. Street lamps (1.0x speed, spawn every 500px) + +**Lamp Lights:** +- Glow effect below each lamp +- Scrolls with lamp + +**Screen Shake:** +- Triggers on wrong answer +- Brief shake effect +- Returns to normal + +**Cool Mode Visual:** +- Purple tint on character sprites +- Applied during 2-second cool mode window + +### Audio System + +**Music Tracks:** +1. **Menu/Story**: Alex Bouncy Mix (looped, 50% volume) +2. **Early Gameplay**: Tedox (plays once, 50% volume) +3. **Extended Gameplay**: Neon Owl (loops, 50% volume) + - Auto-starts when Tedox ends +4. **Game Over**: Game Over II (plays once, 50% volume) + +**Music Transitions:** +- Start → Story: Continue menu music +- Story → Playing: Switch to Tedox +- Tedox ends → Neon Owl (seamless) +- Playing → Game Over: Switch to game over music +- Any state → Start: Switch to menu music + +**Mute Toggle:** +- Available on all screens +- Mutes/unmutes all audio +- State persists across game states + +### HUD Display (During Gameplay) + +**Top bar shows:** +- Score (left) +- Lives (middle, as count) +- Timer (right) + +**Question Overlay:** +- Appears when obstacle enters collision zone +- Semi-transparent background +- Perseus widget rendered (numeric-input or radio) +- Submit button +- Feedback message on answer + +### Input Handling + +**Mouse:** +- Click buttons (start, next, play again, mute) +- Click to answer questions (radio choices) + +**Keyboard:** +- Type numeric answers +- Enter to submit + +**Perseus Widget:** +- Integrated via ServerItemRenderer +- Full Perseus scoring system +- Supports numeric-input and radio widgets + +## Edge Cases & Special Behaviors + +### Multiple Obstacles + +- Only one obstacle on screen at a time in current implementation +- Spawn interval ensures spacing + +### Obstacle Miss + +- If player doesn't answer before obstacle passes, nothing happens +- No penalty for missed questions +- Next obstacle will spawn on schedule + +### Jump Mechanic (Final Question Only) + +- Final obstacle can be jumped over +- Jump triggers car racing animation +- Leads to bonus scene + +### Music Overlap Prevention + +- Stopping one track before starting another +- Handles browser autoplay restrictions gracefully + +### Screen Shake Timing + +- Shake effect is brief (< 500ms) +- Doesn't affect gameplay +- Visual feedback only + +### Benevolence Message + +- Shows briefly after alien abduction +- Humorous element +- Doesn't affect gameplay + +### Game Loop Performance + +- Runs at 60fps (requestAnimationFrame) +- State updates throttled (React setState) +- Dual state/ref pattern for real-time values + +## Known Issues/Quirks + +1. **Dual State Pattern**: Some values stored in both state and refs for performance +2. **Complex Dependencies**: Large useEffect dependency arrays +3. **Tight Coupling**: Game logic mixed with rendering +4. **State Management**: 30+ state variables +5. **No Pause**: Game can't be paused once started +6. **No Save**: No progress saving between sessions +7. **Fixed Difficulty**: No difficulty settings +8. **One Life Type**: No way to earn extra lives + +## UI/UX Details + +### Button Styles + +- Hover effects on clickable images +- Cursor changes to pointer +- No disabled states + +### Question Display + +- Question appears in overlay +- Semi-transparent dark background +- White text +- Clear submit button + +### Feedback Messages + +- Correct: Green text, positive message +- Incorrect: Red text, negative message +- Brief display duration + +### Score Display + +- Large, readable font +- Updates immediately on correct answer + +### Lives Display + +- Numeric count +- Updates immediately on loss + +### Timer Display + +- Always visible during gameplay +- Countdown format to midnight +- Updates in real-time + +## Assets Used + +**Character Sprites:** +- run1-6.png (6 running frames, 128x128) +- impact.png (impact effect, 128x128) + +**Obstacles:** +- car1.png, car2.png (car obstacles, 256x256) + +**Aliens:** +- alien1-3.png (3 alien frames, 300x300) +- beam.png (abduction beam) + +**Backgrounds:** +- sky.png (800x600) +- city-far.png, city-semi-far.png, city-semi-close.png, city-close.png +- streetlamp.png, lamplight.png + +**UI:** +- title.png, start.png, next.png +- victory.png, lose.png +- mute.png, unmute.png +- story1-7.png (7 story pages) + +**Audio:** +- alexbouncymix2.ogg (menu music) +- Zodik-Tedox.ogg (gameplay music) +- Zodik-NeonOwl.ogg (extended gameplay music) +- GameOverII.ogg (game over music) + +--- + +**This document captures the current behavior for verification after refactoring.** diff --git a/packages/perseus/src/games/crash-course/plans/PERFORMANCE_BASELINE.md b/packages/perseus/src/games/crash-course/plans/PERFORMANCE_BASELINE.md new file mode 100644 index 00000000000..efda4ac04e5 --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PERFORMANCE_BASELINE.md @@ -0,0 +1,189 @@ +# Performance Baseline - Crash Course Game + +## Test Environment + +- **Browser**: Chrome 131 +- **Date**: 2025-01-07 +- **Build**: Development mode (Storybook) +- **Device**: [To be filled in when measuring] +- **OS**: macOS 24.5.0 (Darwin) + +## How to Measure + +### Frame Rate (FPS) +1. Open Storybook: `pnpm storybook` +2. Navigate to Games > Crash Course +3. Open Chrome DevTools (F12) +4. Go to Performance tab +5. Click "Record" button +6. Start the game and play for 30 seconds +7. Stop recording +8. Check the FPS chart for: + - Average FPS + - Minimum FPS + - Frame time (should be ~16-17ms for 60fps) + +### Load Time +1. Open Performance tab before loading page +2. Start recording +3. Refresh page +4. Stop when game is interactive +5. Check timeline for: + - Asset loading time + - JavaScript execution time + - Time to interactive + +### Memory Usage +1. Open Chrome DevTools Memory tab +2. Take heap snapshot before starting game +3. Play game for 1 minute +4. Take another snapshot +5. Play for 5 minutes total +6. Take final snapshot +7. Compare memory growth + +### CPU Usage +1. Use Activity Monitor (macOS) or Task Manager (Windows) +2. Monitor Chrome Helper process +3. Record CPU % during: + - Idle (game loaded but not started) + - Active gameplay + - Heavy scenes (alien abduction, multiple obstacles) + +## Measurements (To Be Filled) + +### Frame Rate + +``` +Average FPS: ___ fps (target: 58-60) +Minimum FPS: ___ fps (should stay above 55) +Frame time: ___ ms (target: 16-17ms) +Frame drops: ___ (count any drops below 50fps) +``` + +**Notes:** +- When do frame drops occur? (e.g., alien abduction, multiple animations) +- Are frame rates consistent throughout 5-minute gameplay? + +### Load Time + +``` +Asset loading: ___ seconds +- Image assets: ___ seconds +- Audio assets: ___ seconds + +JavaScript execution: ___ ms +Game initialization: ___ ms +Time to interactive: ___ seconds (total) +``` + +**Notes:** +- Which assets take longest to load? +- Is there any blocking during load? + +### Memory Usage + +``` +Initial (game loaded): ___ MB +After 1 minute: ___ MB +After 5 minutes: ___ MB +Memory growth rate: ___ MB/minute + +Peak memory: ___ MB +Memory stable: Yes/No +``` + +**Notes:** +- Are there memory leaks? +- Does memory keep growing or stabilize? +- Check for leaked listeners or intervals + +### CPU Usage + +``` +Idle (game loaded): ___ % +During gameplay: ___ % +Peak CPU (heavy scenes): ___ % + +Average over 5 minutes: ___ % +``` + +**Notes:** +- Is CPU usage reasonable? +- Any unexpected spikes? + +### Network + +``` +Total asset size: ___ MB +Number of requests: ___ +Caching: ___ (describe caching behavior) +``` + +## Performance Bottlenecks to Watch + +### Potential Issues + +1. **Canvas Drawing** + - Multiple sprites drawn each frame + - Parallax layers (5 layers) + - Character animation (6 frames) + - Are draw calls optimized? + +2. **State Management** + - 30+ state variables + - Frequent setState calls + - Dual state/ref pattern + - Could cause unnecessary re-renders + +3. **Game Loop** + - requestAnimationFrame + React + - Mixing game loop with React rendering + - Potential timing issues + +4. **Asset Loading** + - 30+ images loaded on mount + - 4 audio files + - Sequential vs parallel loading? + +5. **Audio Management** + - Multiple audio tracks + - Transitions between tracks + - Memory usage of audio buffers + +6. **Perseus Widget Rendering** + - ServerItemRenderer re-rendering + - Question widget complexity + - Math rendering (MathJax) + +## Comparison Points for After Refactoring + +After refactoring to game engine architecture, we expect: + +**Improvements:** +- ✅ More consistent frame rate (engine at 60fps, React throttled) +- ✅ Lower CPU usage (less React re-rendering) +- ✅ Better memory stability (cleaner lifecycle) +- ✅ Faster initialization (optimized asset loading) + +**Should maintain:** +- ✅ Same visual quality +- ✅ Same responsiveness +- ✅ Same load time (or better) + +**Accept if:** +- Frame rate stays 55-60fps +- No significant memory leaks +- Load time under 3 seconds +- Gameplay feels smooth + +## Notes + +- Test in both development and production builds +- Test on different machines (if possible) +- Test with browser extensions disabled +- Clear cache between tests for consistency + +--- + +**Fill in measurements before starting refactoring to establish baseline.** diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_0_testing_foundation.md b/packages/perseus/src/games/crash-course/plans/PHASE_0_testing_foundation.md new file mode 100644 index 00000000000..97c7dd935c5 --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_0_testing_foundation.md @@ -0,0 +1,360 @@ +# Phase 0: Testing Foundation & Performance Baselines + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Establish testing infrastructure and capture current performance metrics BEFORE making any changes. + +## Why This Phase is Critical + +The subagent review identified "No Testing Strategy" as the #1 critical issue. Without baseline tests and performance metrics, we have no way to verify the refactored game works identically to the original. This phase addresses that risk. + +## Tasks + +### Task 1: Set Up Test Infrastructure +- **What**: Configure Jest/Vitest for testing the game +- **Why**: Need test runner before writing tests +- **Implementation notes**: + - Check if Perseus already has test setup + - If not, add minimal test configuration + - Install @testing-library/react if needed + - Ensure tests can import from games/crash-course +- **Files affected**: + - Check: `package.json`, `jest.config.js` or `vitest.config.ts` + - Possibly add test config if missing +- **Acceptance criteria**: + - Can run `pnpm test` successfully + - Test runner finds test files + - Can import game code in tests + +### Task 2: Create Gameplay Snapshot Tests +- **What**: Write tests that capture current game behavior +- **Why**: Verify refactored game behaves identically +- **Implementation notes**: + - Test game state transitions (start → story → playing → end) + - Test obstacle spawning logic + - Test collision detection (when does player lose life?) + - Test question generation (all 4 types) + - Test scoring logic + - Use snapshot testing where appropriate +- **Files affected**: + - New: `packages/perseus/src/__docs__/math-blaster-game.test.tsx` + - New: `packages/perseus/src/__docs__/math-blaster-utils.test.ts` +- **Acceptance criteria**: + - At least 10 baseline tests written + - All tests passing + - Cover critical game logic + - Can re-run after refactoring + +**Example tests:** +```typescript +describe("Math Blaster Game (Baseline)", () => { + describe("Game State Transitions", () => { + it("starts in 'start' state", () => { + const {container} = render(); + expect(container.querySelector('.startScreen')).toBeInTheDocument(); + }); + + it("transitions to story after clicking start", async () => { + const user = userEvent.setup(); + const {container} = render(); + await user.click(screen.getByAltText('Start Game')); + expect(container.querySelector('.storyScreen')).toBeInTheDocument(); + }); + }); + + describe("Obstacle Generation", () => { + it("creates obstacle with question", () => { + const obstacle = createObstacle(800); + expect(obstacle).toHaveProperty('id'); + expect(obstacle).toHaveProperty('question'); + expect(obstacle).toHaveProperty('x', 800); + expect(obstacle).toHaveProperty('answered', false); + }); + + it("generates all 4 question types eventually", () => { + const types = new Set(); + for (let i = 0; i < 100; i++) { + const obstacle = createObstacle(800); + const content = obstacle.question.question.content; + if (content.includes('+')) types.add('addition'); + if (content.includes('-')) types.add('subtraction'); + if (content.includes('\\times')) types.add('multiplication'); + if (content.includes('\\div')) types.add('division'); + } + expect(types.size).toBe(4); + }); + }); + + describe("Collision Detection", () => { + it("detects collision when obstacle reaches collision zone", () => { + // Test collision logic + const obstacleX = 100; + const obstacleWidth = 154; + const collisionZoneX = 120; + const characterX = 100; + + const isInZone = obstacleX < collisionZoneX && + obstacleX + obstacleWidth > characterX; + expect(isInZone).toBe(true); + }); + }); +}); +``` + +### Task 3: Capture Performance Baselines +- **What**: Measure current game performance (FPS, load time, memory) +- **Why**: Verify refactor doesn't regress performance +- **Implementation notes**: + - Use Chrome DevTools Performance tab + - Record 30-second gameplay session + - Measure FPS (should be ~60fps) + - Measure memory usage + - Measure asset load time + - Document findings in a baseline file +- **Files affected**: + - New: `PERFORMANCE_BASELINE.md` (documentation) +- **Acceptance criteria**: + - FPS measured and documented + - Memory usage captured + - Load time measured + - Have numbers to compare against later + +**Baseline document template:** +```markdown +# Performance Baseline - Crash Course Game + +## Test Environment +- Browser: Chrome 120 +- Date: 2025-01-XX +- Build: Development mode +- Device: [Your device specs] + +## Measurements + +### Frame Rate +- Average FPS: 58-60 fps +- Minimum FPS: 55 fps (during heavy scenes) +- Frame time: 16-17ms + +### Load Time +- Asset loading: 2.1 seconds +- Game initialization: 320ms +- Time to interactive: 2.5 seconds + +### Memory +- Initial: 45MB +- After 1 minute: 52MB +- After 5 minutes: 58MB +- Memory stable (no major leaks) + +### CPU Usage +- Idle: 2-5% +- During gameplay: 15-25% +- Spikes during spawning: up to 35% + +## Notes +- Performance good on modern machines +- Some frame drops when multiple obstacles on screen +- Asset loading could be optimized +``` + +### Task 4: Document Current Behavior +- **What**: Write down how the game currently works +- **Why**: Reference for verifying refactored game +- **Implementation notes**: + - Document game mechanics (lives, scoring, alien abduction) + - Document edge cases (what happens when out of lives?) + - Document audio behavior (when music changes) + - Document visual effects (shake, cool mode, benevolence message) + - Screenshot each game state +- **Files affected**: + - New: `ORIGINAL_BEHAVIOR.md` (documentation) +- **Acceptance criteria**: + - All game mechanics documented + - Screenshots of each state + - Edge cases noted + - Can use as checklist after refactoring + +### Task 5: Create Test Utilities +- **What**: Build helper functions for testing the game +- **Why**: Make writing tests easier +- **Implementation notes**: + - Mock canvas context + - Mock audio elements + - Helper to advance game time + - Helper to spawn obstacles + - Helper to simulate answers +- **Files affected**: + - New: `packages/perseus/src/__docs__/__test-utils__/game-test-utils.ts` +- **Acceptance criteria**: + - Utilities make testing easier + - Can mock expensive operations (canvas, audio) + - Reusable across test files + +**Example utilities:** +```typescript +// __test-utils__/game-test-utils.ts +export function mockCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + const ctx = { + clearRect: jest.fn(), + drawImage: jest.fn(), + fillRect: jest.fn(), + // ... other canvas methods + }; + canvas.getContext = jest.fn(() => ctx as any); + return canvas; +} + +export function mockAudio(): HTMLAudioElement { + const audio = { + play: jest.fn().mockResolvedValue(undefined), + pause: jest.fn(), + currentTime: 0, + volume: 1, + // ... other audio properties + }; + return audio as any; +} + +export function advanceGameTime(ms: number) { + jest.advanceTimersByTime(ms); +} +``` + +### Task 6: Test Visual Regression (Optional) +- **What**: Capture screenshots of current game for visual comparison +- **Why**: Verify refactored game looks identical +- **Implementation notes**: + - Use Storybook + Chromatic (if available) + - Or manually capture screenshots + - Save screenshots of: start screen, story pages, gameplay, victory, game over +- **Files affected**: + - Screenshots saved in `__test-data__/screenshots/` +- **Acceptance criteria**: + - Screenshots of all game states + - Can compare visually after refactoring + +### Task 7: Create Test Data +- **What**: Save example Perseus questions for testing +- **Why**: Consistent test data for question rendering +- **Implementation notes**: + - Generate examples of each question type (addition, subtraction, etc.) + - Save as TypeScript constants + - Use in tests for consistency +- **Files affected**: + - New: `packages/perseus/src/__docs__/__testdata__/questions.testdata.ts` +- **Acceptance criteria**: + - Example questions for all 4 types + - Can import in tests + - Realistic Perseus item format + +**Example:** +```typescript +// __testdata__/questions.testdata.ts +import type {PerseusItem} from "@khanacademy/perseus-core"; + +export const additionQuestion: PerseusItem = { + question: { + content: "What is $12 + 8$?\n\n[[☃ numeric-input 1]]", + images: {}, + widgets: { + "numeric-input 1": { + type: "numeric-input", + options: { + answers: [{value: 20, status: "correct"}], + }, + // ... full widget definition + }, + }, + }, + // ... rest of PerseusItem +}; + +export const divisionQuestion: PerseusItem = { /* ... */ }; +// etc. +``` + +### Task 8: Add Continuous Integration Tests +- **What**: Ensure tests run in CI pipeline +- **Why**: Catch regressions automatically +- **Implementation notes**: + - Verify tests run in Perseus CI + - If not, add test step to CI config + - Ensure tests fail CI if they don't pass +- **Files affected**: + - Check: `.github/workflows/` or CI config +- **Acceptance criteria**: + - Tests run automatically on commits + - CI fails if tests fail + - Can see test results in pull requests + +## Testing Considerations + +### What to Test: +- ✅ Game state transitions +- ✅ Obstacle spawning and movement +- ✅ Collision detection +- ✅ Question generation (all types) +- ✅ Scoring logic +- ✅ Life system +- ✅ Timer/victory condition +- ✅ Perseus widget integration + +### What NOT to Test (yet): +- ❌ Canvas rendering output (hard to test, verify manually) +- ❌ Audio playback (test mocking is enough) +- ❌ Specific sprite positions (too fragile) +- ❌ Animation frame timing (hard to test reliably) + +### Testing Philosophy: +- Focus on **behavior**, not implementation +- Test **what the user experiences**, not internal state +- Make tests **resilient to refactoring** +- Keep tests **fast** (mock expensive operations) + +## Deliverables + +After this phase, you should have: +1. ✅ Test infrastructure configured +2. ✅ 10+ baseline tests written and passing +3. ✅ Performance baselines documented +4. ✅ Current behavior documented +5. ✅ Test utilities created +6. ✅ Visual regression baseline (optional) +7. ✅ Test data for Perseus questions +8. ✅ CI integration verified + +## Benefits + +- **Confidence**: Can refactor knowing tests will catch regressions +- **Documentation**: Tests document current behavior +- **Performance**: Have numbers to compare against +- **Speed**: Test utilities make future tests faster to write +- **Safety net**: Can experiment knowing tests will catch breaks + +## Time Estimate + +- Test setup: 30 minutes +- Baseline tests: 1-1.5 hours +- Performance baselines: 30 minutes +- Documentation: 30 minutes +- Test utilities: 30 minutes + +**Total: 2-3 hours** + +## Success Criteria + +- [ ] Test runner configured and working +- [ ] At least 10 baseline tests written +- [ ] All baseline tests passing +- [ ] Performance baselines captured and documented +- [ ] Current behavior documented with screenshots +- [ ] Test utilities created +- [ ] Test data saved +- [ ] CI runs tests automatically +- [ ] Ready to begin refactoring with confidence + +--- + +**After this phase**, you can begin refactoring knowing you have a safety net to catch regressions! diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_1_COMPLETE.md b/packages/perseus/src/games/crash-course/plans/PHASE_1_COMPLETE.md new file mode 100644 index 00000000000..b313615e9ec --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_1_COMPLETE.md @@ -0,0 +1,253 @@ +# Phase 1 Complete: Setup & Engine Architecture + +**Date**: 2025-01-07 +**Duration**: ~1.5 hours +**Status**: ✅ Complete + +## Summary + +Phase 1 established the foundation for the Crash Course refactoring by creating the directory structure, organizing all assets, and designing the engine API. + +## Completed Tasks + +### Directory Structure ✅ +Created complete directory hierarchy: +``` +packages/perseus/src/games/ +├── crash-course/ +│ ├── assets/ +│ │ ├── sprites/ (13 files) +│ │ ├── backgrounds/ (7 files) +│ │ ├── ui/ (10 files) +│ │ ├── audio/ (4 files) +│ │ └── story/ (7 files) +│ ├── engine/ +│ │ ├── systems/ +│ │ └── utils/ +│ ├── components/ +│ ├── __tests__/ +│ ├── __testdata__/ +│ ├── __test-utils__/ +│ └── plans/ +└── shared/ + └── perseus/ +``` + +### Assets Organized ✅ +Moved 41 asset files from `__docs__/` to organized folders: +- **Sprites**: 13 files (character, aliens, cars, effects) +- **Backgrounds**: 7 files (sky, city layers, lamps) +- **UI**: 10 files (buttons, screens, effects) +- **Audio**: 4 files (music tracks) +- **Story**: 7 files (story sequence) + +All moves used `git mv` to preserve history. + +### Engine API Designed ✅ + +**Created files**: +1. `engine/types.ts` - Core type definitions + - GameState, CharacterState, GameUIState + - Obstacle, EngineConfig, SpriteConfig + - GAME_CONSTANTS + +2. `engine/crash-course-engine.ts` - Main engine class + - Implements PerseusGameEngine interface + - Game loop structure (60fps) + - Perseus integration methods + - Action handling + +3. `engine/systems/system-interfaces.ts` - System interfaces + - AssetLoader interface + - AudioSystem interface + - RenderSystem interface + +4. `shared/perseus/perseus-game-engine.ts` - Standard integration interface + - getCurrentQuestion() + - submitAnswer() + - onQuestionChange() + - Documentation with examples + +### Documentation Created ✅ + +1. **README.md** - Main game documentation + - Architecture overview + - Directory structure + - Current status + - Key design decisions + +2. **assets/ASSETS.md** - Asset inventory + - Complete asset list + - Import examples + - Asset manifest structure + - File formats and sizes + +3. **PHASE_1_COMPLETE.md** (this file) + - Summary of work completed + - Architecture decisions + - Next steps + +## Architecture Decisions + +### 1. Custom Game Engine Pattern +**Decision**: Use pure TypeScript engine separate from React +**Rationale**: +- Avoids React + requestAnimationFrame complexity +- 60fps game loop independent of React rendering +- Cleaner separation of concerns +- Easier to test + +**Impact**: +- Phase 2 will build the engine +- React becomes thin UI wrapper +- Better performance + +### 2. Perseus Integration Interface +**Decision**: Create standard `PerseusGameEngine` interface +**Rationale**: +- Consistent pattern for all educational games +- Clear contract between engine and React +- Reusable for future games + +**Location**: `games/shared/perseus/` +**Impact**: Other games can implement same interface + +### 3. System-Based Architecture +**Decision**: Use systems (AssetLoader, AudioSystem, RenderSystem) +**Rationale**: +- Single responsibility +- Easier to test +- Reusable across games + +**Impact**: Phase 2 will implement these systems + +### 4. Multi-Entity Sprite System +**Decision**: Build sprite manager from the start +**Rationale**: +- Prevents future refactoring +- Handles character, aliens, obstacles +- Layer-based rendering + +**Impact**: Phase 2 will build SpriteAnimator, AnimatedSprite, SpriteManager + +### 5. Asset Organization +**Decision**: Organize by type (sprites, backgrounds, ui, audio, story) +**Rationale**: +- Clear organization +- Easy to find assets +- Matches asset types in game + +**Impact**: AssetLoader will use manifest structure + +## Key Files Created + +### Engine Core +- `engine/types.ts` (142 lines) - Type definitions +- `engine/crash-course-engine.ts` (127 lines) - Main engine class API +- `engine/systems/system-interfaces.ts` (116 lines) - System interfaces + +### Perseus Integration +- `shared/perseus/perseus-game-engine.ts` (99 lines) - Standard interface + +### Documentation +- `README.md` (159 lines) - Main documentation +- `assets/ASSETS.md` (218 lines) - Asset documentation +- `plans/PHASE_1_COMPLETE.md` (this file) + +## Assets Moved + +**Total**: 41 files + +**Sprites** (13): +- run1-6.png, impact.png (character) +- alien1-3.png, beam.png (aliens) +- car1-2.png (obstacles) + +**Backgrounds** (7): +- sky.png, city-far.png, city-semi-far.png, city-semi-close.png, city-close.png +- streetlamp.png, lamplight.png + +**UI** (10): +- title.png, start.png, next.png +- victory.png, lose.png +- mute.png, unmute.png +- bonus1-2.png, skid.png + +**Audio** (4): +- alexbouncymix2.ogg (menu) +- Zodik - Tedox.ogg (gameplay) +- Zodik - Neon Owl.ogg (extended) +- Game Over II.ogg (game over) + +**Story** (7): +- story1-7.png + +## Verification + +✅ All directories created +✅ All 41 assets moved with `git mv` +✅ Engine API designed and documented +✅ System interfaces defined +✅ Perseus integration interface created +✅ Documentation written +✅ Directory structure verified + +## Testing Status + +- Phase 0 tests still passing (11 tests) +- No new tests added in Phase 1 (architecture only) +- Phase 2 will add system tests + +## Git Status + +**Files added**: +- 7 TypeScript files (engine, types, interfaces) +- 3 Markdown files (README, ASSETS, PHASE_1_COMPLETE) + +**Files moved**: +- 41 asset files (sprites, backgrounds, ui, audio, story) + +**Ready for commit**: Yes +- Clean architecture +- All assets organized +- Documentation complete + +## Time Breakdown + +- Directory creation: 10 min +- Asset organization: 20 min +- Type design: 30 min +- Engine API design: 20 min +- System interfaces: 15 min +- Documentation: 25 min + +**Total**: ~2 hours (estimated 2-3 hours) + +## Next Steps + +### Phase 2: Build Engine (5-7 hours) +Now that the architecture is designed, Phase 2 will: + +1. **Build AssetLoader** - Load sprites, backgrounds, audio +2. **Build RenderSystem** - Canvas drawing utilities +3. **Build AudioSystem** - Music track management +4. **Build Sprite System** - SpriteAnimator, AnimatedSprite, SpriteManager +5. **Implement Engine Core** - Game loop, physics, collision +6. **Move Game Logic** - Transfer logic from React to engine +7. **Test Systems** - Unit test each system + +After Phase 2, the engine will be fully functional (but still using old React component). + +## Success Criteria + +✅ Directory structure follows plan +✅ All assets moved and organized +✅ Engine API designed and typed +✅ Perseus integration interface created +✅ System interfaces defined +✅ Documentation complete +✅ Can proceed to Phase 2 + +--- + +**Phase 1 Complete!** Ready to begin Phase 2: Build Engine. diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_1_setup_assets_architecture.md b/packages/perseus/src/games/crash-course/plans/PHASE_1_setup_assets_architecture.md new file mode 100644 index 00000000000..59c3645d69f --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_1_setup_assets_architecture.md @@ -0,0 +1,821 @@ +# Phase 1: Setup, Assets & Engine Architecture + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Create directory structure, organize assets, and design the game engine API. + +**Estimated Effort**: 2-3 hours + +## Overview + +This phase establishes the foundation for the refactoring. We'll create the new folder structure, move and organize all 40+ asset files, and design the interfaces between the game engine and React components. No behavior changes yet - just organization and architecture design. + +**Important**: By the end of this phase, the original game should still work in its current location while we set up the new structure. + +## Tasks + +### Task 1: Create Directory Structure +- **What**: Set up the new `games/crash-course/` folder hierarchy +- **Why**: Establishes the foundation for all subsequent work +- **Implementation notes**: + - Create base directory at `packages/perseus/src/games/crash-course/` + - Create asset subdirectories for organization + - Create engine directory structure + - Create placeholder README files +- **Files affected**: + - New: `packages/perseus/src/games/` (directory) + - New: `packages/perseus/src/games/crash-course/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/sprites/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/backgrounds/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/ui/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/audio/` (directory) + - New: `packages/perseus/src/games/crash-course/assets/story/` (directory) + - New: `packages/perseus/src/games/crash-course/engine/` (directory) + - New: `packages/perseus/src/games/crash-course/engine/systems/` (directory) + - New: `packages/perseus/src/games/crash-course/engine/utils/` (directory) + - New: `packages/perseus/src/games/crash-course/components/` (directory) +- **Acceptance criteria**: + - All directories exist + - Structure follows Perseus conventions + - Can import from these directories + +**Directory structure:** +``` +packages/perseus/src/games/crash-course/ +├── assets/ +│ ├── sprites/ +│ ├── backgrounds/ +│ ├── ui/ +│ ├── audio/ +│ └── story/ +├── engine/ +│ ├── systems/ +│ └── utils/ +├── components/ +└── README.md (placeholder) +``` + +--- + +### Task 2: Create Shared Perseus Directory +- **What**: Set up shared directory for Perseus game interfaces +- **Why**: Standard integration pattern for all educational games +- **Implementation notes**: + - Create `games/shared/` directory + - Create `games/shared/perseus/` subdirectory + - This will hold the PerseusGameEngine interface +- **Files affected**: + - New: `packages/perseus/src/games/shared/` (directory) + - New: `packages/perseus/src/games/shared/perseus/` (directory) +- **Acceptance criteria**: + - Shared directory exists + - Can import from games/shared + +--- + +### Task 3: Move Sprite Assets +- **What**: Move character, alien, and car sprites to the sprites folder +- **Why**: Groups related visual assets together +- **Implementation notes**: + - Move character animation frames (run1-6, impact, idle) + - Move alien sprites (alien1-3) + - Move car sprites (car1-2) + - Move special effects (beam) + - Use `git mv` to preserve history +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/sprites/` + - Specific files: + - `run1.png` → `sprites/run1.png` + - `run2.png` → `sprites/run2.png` + - `run3.png` → `sprites/run3.png` + - `run4.png` → `sprites/run4.png` + - `run5.png` → `sprites/run5.png` + - `run6.png` → `sprites/run6.png` + - `impact.png` → `sprites/impact.png` + - `idle.png` → `sprites/idle.png` + - `alien1.png` → `sprites/alien1.png` + - `alien2.png` → `sprites/alien2.png` + - `alien3.png` → `sprites/alien3.png` + - `car1.png` → `sprites/car1.png` + - `car2.png` → `sprites/car2.png` + - `beam.png` → `sprites/beam.png` +- **Acceptance criteria**: + - All sprite files moved to correct directory + - Git history preserved + - No duplicate files remain + +--- + +### Task 4: Move Background Assets +- **What**: Move parallax layers and environment graphics +- **Why**: Separates background elements from interactive sprites +- **Implementation notes**: + - Move sky, city layers, and ground elements + - Move street lamp and lamp light + - Use `git mv` to preserve history +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/backgrounds/` + - Specific files: + - `sky.png` → `backgrounds/sky.png` + - `city-far.png` → `backgrounds/city-far.png` + - `city-semi-far.png` → `backgrounds/city-semi-far.png` + - `city-semi-close.png` → `backgrounds/city-semi-close.png` + - `city-close.png` → `backgrounds/city-close.png` + - `streetlamp.png` → `backgrounds/streetlamp.png` + - `lamplight.png` → `backgrounds/lamplight.png` +- **Acceptance criteria**: + - All background files moved + - Git history preserved + +--- + +### Task 5: Move UI Assets +- **What**: Move buttons, screens, and UI elements +- **Why**: Keeps user interface assets separate from game graphics +- **Implementation notes**: + - Move all button images (start, next, mute, unmute) + - Move screen images (title, victory, lose) + - Move bonus scene images + - Use `git mv` to preserve history +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/ui/` + - Specific files: + - `title.png` → `ui/title.png` + - `start.png` → `ui/start.png` + - `next.png` → `ui/next.png` + - `mute.png` → `ui/mute.png` + - `unmute.png` → `ui/unmute.png` + - `victory.png` → `ui/victory.png` + - `lose.png` → `ui/lose.png` + - `bonus1.png` → `ui/bonus1.png` + - `bonus2.png` → `ui/bonus2.png` + - `skid.png` → `ui/skid.png` +- **Acceptance criteria**: + - All UI assets moved + - Easy to find buttons vs screens + +--- + +### Task 6: Move Story Assets +- **What**: Move all 7 story page images +- **Why**: Keeps narrative content separate and organized +- **Implementation notes**: + - Move story1 through story7 images + - Keep sequential naming + - Use `git mv` to preserve history +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/story*.png` + - Move to: `packages/perseus/src/games/crash-course/assets/story/` + - Specific files: + - `story1.png` → `story/story1.png` + - `story2.png` → `story/story2.png` + - `story3.png` → `story/story3.png` + - `story4.png` → `story/story4.png` + - `story5.png` → `story/story5.png` + - `story6.png` → `story/story6.png` + - `story7.png` → `story/story7.png` +- **Acceptance criteria**: + - All story images in story/ folder + - Sequential numbering preserved + +--- + +### Task 7: Move Audio Assets +- **What**: Move all audio files (.ogg) +- **Why**: Separates audio from visual assets +- **Implementation notes**: + - Move all 4 audio files + - Consider renaming for clarity + - Use `git mv` to preserve history +- **Files affected**: + - Move from: `packages/perseus/src/__docs__/*.ogg` + - Move to: `packages/perseus/src/games/crash-course/assets/audio/` + - Specific files: + - `alexbouncymix2.ogg` → `audio/menu-theme.ogg` + - `Zodik - Tedox.ogg` → `audio/game-theme-1.ogg` + - `Zodik - Neon Owl.ogg` → `audio/game-theme-2.ogg` + - `Game Over II.ogg` → `audio/game-over-theme.ogg` +- **Acceptance criteria**: + - All audio files moved + - Names are clear and descriptive + +--- + +### Task 8: Design Engine API and Types +- **What**: Define the interfaces between engine and React +- **Why**: Clear contract before implementation, guides Phase 2 +- **Implementation notes**: + - Create type definitions for engine + - Define GameUIState (what React needs to know) + - Define engine public methods + - Design Perseus integration interface + - Document with JSDoc comments +- **Files affected**: + - New: `games/crash-course/engine/types.ts` + - New: `games/shared/perseus/PerseusGameEngine.ts` +- **Acceptance criteria**: + - Types defined and documented + - Clear API contract + - Perseus interface defined + - Ready for Phase 2 implementation + +**Example types file:** +```typescript +// games/crash-course/engine/types.ts + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Game state values + */ +export type GameState = + | "start" // Title screen + | "story" // Story sequence (7 pages) + | "playing" // Active gameplay + | "carBonus" // Car bonus scene + | "gameover" // Lost all lives + | "victory"; // Survived 5 minutes + +/** + * Character animation states + */ +export type CharacterState = + | "running" // Normal running + | "coolMode" // After correct answer (purple tint) + | "impact" // Hit obstacle + | "loss"; // Game over + +/** + * UI state exposed to React for rendering + * Engine throttles updates to 10-15fps + */ +export type GameUIState = { + /** Current game state */ + gameState: GameState; + + /** Current story page (1-7) */ + storyPage: number; + + /** Player score */ + score: number; + + /** Remaining lives (0-3) */ + lives: number; + + /** Formatted game time (11:55:00 → 00:00:00) */ + gameTime: string; + + /** Current Perseus question to display (or null) */ + currentQuestion: PerseusItem | null; + + /** Whether alien benevolence message should show */ + showBenevolence: boolean; +}; + +/** + * Configuration for engine initialization + */ +export type EngineConfig = { + /** Canvas element for rendering */ + canvas: HTMLCanvasElement; + + /** Callback for UI state updates (throttled) */ + onUIUpdate: (state: GameUIState) => void; + + /** Optional: Muted state */ + muted?: boolean; +}; + +/** + * Obstacle data structure + */ +export type Obstacle = { + id: string; + x: number; + y: number; + width: number; + height: number; + question: PerseusItem; + answered: boolean; + correct: boolean; + jumped?: boolean; + racing?: boolean; + racingStartTime?: number; +}; +``` + +**Perseus interface:** +```typescript +// games/shared/perseus/PerseusGameEngine.ts + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Interface that all educational games must implement for Perseus integration. + * + * This provides a consistent way for games to integrate Perseus questions, + * regardless of the game mechanics. + * + * Example usage: + * ```typescript + * class MyGameEngine implements PerseusGameEngine { + * getCurrentQuestion() { return this.currentQuestion; } + * submitAnswer(correct, points) { this.handleAnswer(correct, points); } + * onQuestionChange(cb) { this.questionCallback = cb; } + * } + * ``` + */ +export interface PerseusGameEngine { + /** + * Get the current question that should be displayed to the player. + * Returns null if no question is active. + * + * The React component will render this using ServerItemRenderer. + */ + getCurrentQuestion(): PerseusItem | null; + + /** + * Called when the player submits an answer to the current question. + * + * @param correct - Whether the answer was correct + * @param earnedPoints - Points earned (from Perseus scoring) + */ + submitAnswer(correct: boolean, earnedPoints: number): void; + + /** + * Register a callback to be notified when the question changes. + * This allows React to re-render when a new question appears. + * + * @param callback - Function called with new question (or null) + */ + onQuestionChange(callback: (question: PerseusItem | null) => void): void; +} +``` + +--- + +### Task 9: Design Engine Public API +- **What**: Document the public methods React will call +- **Why**: Clear contract for Phase 3 (React integration) +- **Implementation notes**: + - Create skeleton CrashCourseEngine class + - Define public methods with JSDoc + - No implementation yet, just signatures + - Document parameters and return values +- **Files affected**: + - New: `games/crash-course/engine/CrashCourseEngine.ts` (skeleton) +- **Acceptance criteria**: + - All public methods defined + - JSDoc documentation complete + - Type signatures correct + - Ready for Phase 2 implementation + +**Example API skeleton:** +```typescript +// games/crash-course/engine/CrashCourseEngine.ts + +import type {GameState, GameUIState, EngineConfig, Obstacle} from "./types"; +import type {PerseusGameEngine} from "../../shared/perseus/PerseusGameEngine"; +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Crash Course game engine. + * + * Handles all game logic, physics, collision detection, and rendering. + * Runs at 60fps independently of React. + * + * Usage: + * ```typescript + * const engine = new CrashCourseEngine({ + * canvas: canvasRef.current, + * onUIUpdate: setUIState, + * }); + * + * await engine.init(); + * engine.start(); + * + * // Later + * engine.stop(); + * ``` + */ +export class CrashCourseEngine implements PerseusGameEngine { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private gameState: GameState = "start"; + + /** + * Create a new game engine instance. + * + * @param config - Engine configuration + */ + constructor(config: EngineConfig) { + // Implementation in Phase 2 + } + + /** + * Initialize the engine and load all assets. + * Must be called before start(). + */ + async init(): Promise { + // Implementation in Phase 2 + } + + /** + * Start the game loop. + * Engine will run at 60fps until stop() is called. + */ + start(): void { + // Implementation in Phase 2 + } + + /** + * Stop the game loop. + */ + stop(): void { + // Implementation in Phase 2 + } + + /** + * Pause the game. + */ + pause(): void { + // Implementation in Phase 2 + } + + /** + * Resume the game. + */ + resume(): void { + // Implementation in Phase 2 + } + + /** + * Get current game state. + */ + getCurrentState(): GameState { + return this.gameState; + } + + /** + * Start the story sequence. + * Transitions from "start" to "story" state. + */ + startStory(): void { + // Implementation in Phase 2 + } + + /** + * Advance to next story page. + * After page 7, transitions to "playing" state. + */ + nextStoryPage(): void { + // Implementation in Phase 2 + } + + /** + * Start gameplay. + * Transitions to "playing" state and starts timer. + */ + startPlaying(): void { + // Implementation in Phase 2 + } + + /** + * Toggle audio mute. + */ + toggleMute(): void { + // Implementation in Phase 2 + } + + // PerseusGameEngine interface + + /** + * Get current Perseus question to display. + * Returns null if no question active. + */ + getCurrentQuestion(): PerseusItem | null { + // Implementation in Phase 2 + return null; + } + + /** + * Submit answer from player. + * + * @param correct - Whether answer was correct + * @param earnedPoints - Points earned + */ + submitAnswer(correct: boolean, earnedPoints: number): void { + // Implementation in Phase 2 + } + + /** + * Register callback for question changes. + */ + onQuestionChange(callback: (question: PerseusItem | null) => void): void { + // Implementation in Phase 2 + } +} +``` + +--- + +### Task 10: Copy Game Files to New Location +- **What**: Copy (don't move yet) game files to new location with new names +- **Why**: Keep original working while setting up new structure +- **Implementation notes**: + - Copy story file as `crash-course.stories.tsx` + - Copy utils as `crash-course-utils.ts` + - Copy CSS as `crash-course.module.css` + - Copy car bonus scene files + - Keep originals in __docs__ until Phase 3 +- **Files affected**: + - Copy: `__docs__/math-blaster-game.stories.tsx` → `games/crash-course/crash-course.stories.tsx` + - Copy: `__docs__/math-blaster-utils.ts` → `games/crash-course/crash-course-utils.ts` + - Copy: `__docs__/math-blaster-game.module.css` → `games/crash-course/crash-course.module.css` + - Copy: `__docs__/car-bonus-scene.tsx` → `games/crash-course/car-bonus-scene.tsx` + - Copy: `__docs__/car-bonus-scene.module.css` → `games/crash-course/car-bonus-scene.module.css` +- **Acceptance criteria**: + - Files copied to new location + - Original files still work + - New location files have updated imports + +--- + +### Task 11: Update Import Paths in Copied Files +- **What**: Update asset import statements in the copied files +- **Why**: Make copied files work with new asset locations +- **Implementation notes**: + - Update image imports to use new asset paths + - Update audio imports to use new paths + - Update CSS imports + - Files should compile without errors +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` + - Modified: `games/crash-course/car-bonus-scene.tsx` +- **Acceptance criteria**: + - All imports resolve correctly + - No import errors when building + - TypeScript doesn't complain + +**Example import changes:** +```typescript +// Before (in __docs__) +import run1Img from "./run1.png"; +import styles from "./math-blaster-game.module.css"; + +// After (in games/crash-course/) +import run1Img from "./assets/sprites/run1.png"; +import styles from "./crash-course.module.css"; +``` + +--- + +### Task 12: Update Storybook Story Metadata +- **What**: Change Storybook title to "Games/Crash Course" +- **Why**: Make it appear under "Games" section with correct name +- **Implementation notes**: + - Update title in story metadata + - Update component name if needed + - Update description + - Keep as separate story from original (both exist temporarily) +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story appears under "Games" in Storybook + - Title is "Crash Course" not "Math Blaster" + - Both old and new stories work + +**Example:** +```typescript +const meta: Meta = { + title: "Games/Crash Course", + component: CrashCourseGame, + parameters: { + docs: { + description: { + component: ` +# Crash Course + +An endless runner game showcasing Perseus widget integration. +This is the refactored version with game engine architecture. + +**Location**: packages/perseus/src/games/crash-course/ + `, + }, + }, + }, +}; +``` + +--- + +### Task 13: Create Asset Manifest +- **What**: Create a manifest file listing all assets +- **Why**: Centralized asset loading, easy to maintain +- **Implementation notes**: + - List all images with keys + - List all audio with keys + - Export as TypeScript object + - Used by AssetLoader in Phase 2 +- **Files affected**: + - New: `games/crash-course/assets/asset-manifest.ts` +- **Acceptance criteria**: + - All assets listed + - Correct paths + - Type-safe + +**Example:** +```typescript +// games/crash-course/assets/asset-manifest.ts + +import run1Img from "./sprites/run1.png"; +import run2Img from "./sprites/run2.png"; +// ... all other imports + +export const SPRITE_ASSETS = { + run1: run1Img, + run2: run2Img, + run3: run3Img, + run4: run4Img, + run5: run5Img, + run6: run6Img, + impact: impactImg, + idle: idleImg, + alien1: alien1Img, + alien2: alien2Img, + alien3: alien3Img, + car1: car1Img, + car2: car2Img, + beam: beamImg, +} as const; + +export const BACKGROUND_ASSETS = { + sky: skyImg, + cityFar: cityFarImg, + citySemiFar: citySemiFarImg, + citySemiClose: citySemiCloseImg, + cityClose: cityCloseImg, + streetlamp: streetlampImg, + lamplight: lamplightImg, +} as const; + +export const UI_ASSETS = { + title: titleImg, + start: startImg, + next: nextImg, + mute: muteImg, + unmute: unmuteImg, + victory: victoryImg, + lose: loseImg, + bonus1: bonus1Img, + bonus2: bonus2Img, + skid: skidImg, +} as const; + +export const STORY_ASSETS = { + story1: story1Img, + story2: story2Img, + story3: story3Img, + story4: story4Img, + story5: story5Img, + story6: story6Img, + story7: story7Img, +} as const; + +export const AUDIO_ASSETS = { + menuTheme: menuThemeAudio, + gameTheme1: gameTheme1Audio, + gameTheme2: gameTheme2Audio, + gameOverTheme: gameOverThemeAudio, +} as const; + +export const ALL_ASSETS = { + sprites: SPRITE_ASSETS, + backgrounds: BACKGROUND_ASSETS, + ui: UI_ASSETS, + story: STORY_ASSETS, + audio: AUDIO_ASSETS, +} as const; +``` + +--- + +### Task 14: Verify Build and Type Checking +- **What**: Ensure everything compiles and builds correctly +- **Why**: Catch issues early before Phase 2 +- **Implementation notes**: + - Run `pnpm tsc` - should pass + - Run `pnpm lint` - should pass + - Run `pnpm build` - should succeed + - Original game still works in Storybook + - New (incomplete) game appears in Storybook +- **Files affected**: + - N/A (verification task) +- **Acceptance criteria**: + - TypeScript compilation passes + - Linting passes + - Build succeeds + - No import errors + - Original game works + +--- + +### Task 15: Document Phase 1 Completion +- **What**: Create a completion checklist and summary +- **Why**: Verify everything is ready for Phase 2 +- **Implementation notes**: + - Check all directories created + - Check all assets moved + - Check types defined + - Check API documented + - Update main README +- **Files affected**: + - New: `games/crash-course/PHASE_1_COMPLETE.md` +- **Acceptance criteria**: + - All tasks completed + - Checklist verified + - Ready for Phase 2 + +--- + +## Deliverables + +After this phase, you should have: + +### Directory Structure ✅ +``` +games/ +├── crash-course/ +│ ├── assets/ +│ │ ├── sprites/ (14 files) +│ │ ├── backgrounds/ (7 files) +│ │ ├── ui/ (10 files) +│ │ ├── audio/ (4 files) +│ │ ├── story/ (7 files) +│ │ └── asset-manifest.ts +│ ├── engine/ +│ │ ├── CrashCourseEngine.ts (skeleton with API) +│ │ ├── types.ts (complete types) +│ │ ├── systems/ (empty, ready for Phase 2) +│ │ └── utils/ (empty, ready for Phase 2) +│ ├── components/ (empty, ready for Phase 3) +│ ├── crash-course.stories.tsx (copied, imports updated) +│ ├── crash-course-utils.ts (copied) +│ ├── crash-course.module.css (copied) +│ ├── car-bonus-scene.tsx (copied) +│ ├── car-bonus-scene.module.css (copied) +│ └── README.md +└── shared/ + └── perseus/ + └── PerseusGameEngine.ts (interface definition) +``` + +### API Definitions ✅ +- GameState type +- GameUIState type +- Obstacle type +- PerseusGameEngine interface +- CrashCourseEngine public API (skeleton) + +### Assets Organized ✅ +- 42 assets moved to proper subdirectories +- Asset manifest created +- Git history preserved + +### Original Game Still Works ✅ +- Files remain in __docs__ +- Storybook story still functional +- No breaking changes + +--- + +## Testing Considerations + +After this phase: +1. **Build Check**: `pnpm tsc` passes +2. **Lint Check**: `pnpm lint` passes +3. **Storybook Check**: Original game still works +4. **Import Check**: New files compile without errors +5. **Git Check**: History preserved with `git mv` + +--- + +## Benefits After This Phase + +- ✅ **Clean organization** - Assets in logical folders +- ✅ **Clear API contract** - Types and interfaces defined +- ✅ **Ready for Phase 2** - Structure in place +- ✅ **Reversible** - Original game still works +- ✅ **Documented** - API fully specified with JSDoc + +--- + +## Next Phase + +**Phase 2**: Build the game engine with all systems, implementing the API we designed in this phase. + +The foundation is set! 🏗️ diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_2_build_engine.md b/packages/perseus/src/games/crash-course/plans/PHASE_2_build_engine.md new file mode 100644 index 00000000000..24c1ab7f1ae --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_2_build_engine.md @@ -0,0 +1,1048 @@ +# Phase 2: Build Game Engine + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Extract all game logic into the CrashCourseEngine class with supporting systems, including a complete sprite animation system for managing multiple animated entities. + +**Estimated Effort**: 5-7 hours (most complex phase) + +## Overview + +This is the core refactoring work. We're moving all game loop logic, physics, collision detection, and canvas rendering from the React component into a pure TypeScript game engine. The engine will run independently at 60fps and communicate with React via callbacks. + +**Key Innovation**: Multi-entity sprite animation system built from the start, preventing future refactoring. + +## Tasks + +### Task 1: Create Engine Core Structure +- **What**: Set up the basic CrashCourseEngine class with game loop +- **Why**: Foundation for all game logic +- **Implementation notes**: + - Create engine class with constructor, start(), stop(), pause() + - Implement requestAnimationFrame loop + - Track deltaTime between frames + - Set up canvas context + - Add callback for UI state updates (throttled to 10-15fps) +- **Files affected**: + - New: `games/crash-course/engine/CrashCourseEngine.ts` + - New: `games/crash-course/engine/types.ts` +- **Acceptance criteria**: + - Engine can start/stop + - Game loop runs at 60fps + - Can pass canvas reference + - Callback mechanism works + +**Example structure:** +```typescript +// games/crash-course/engine/CrashCourseEngine.ts + +type GameState = "start" | "story" | "playing" | "carBonus" | "gameover" | "victory"; + +type GameUIState = { + gameState: GameState; + storyPage: number; + score: number; + lives: number; + gameTime: string; + currentQuestion: PerseusItem | null; +}; + +export class CrashCourseEngine { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private rafId: number | null = null; + private lastTime: number = 0; + private isRunning: boolean = false; + + private gameState: GameState = "start"; + private uiUpdateCallback: ((state: GameUIState) => void) | null = null; + private frameCount: number = 0; + + constructor(canvas: HTMLCanvasElement, onUIUpdate: (state: GameUIState) => void) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.uiUpdateCallback = onUIUpdate; + } + + start(): void { + this.isRunning = true; + this.lastTime = performance.now(); + this.loop(this.lastTime); + } + + stop(): void { + this.isRunning = false; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } + + pause(): void { + this.isRunning = false; + } + + resume(): void { + if (!this.isRunning) { + this.isRunning = true; + this.lastTime = performance.now(); + this.loop(this.lastTime); + } + } + + private loop = (currentTime: number): void => { + if (!this.isRunning) return; + + const deltaTime = currentTime - this.lastTime; + this.lastTime = currentTime; + + // Update game logic + this.update(deltaTime); + + // Render game + this.render(); + + // Notify React of UI state changes (throttled to 10fps) + this.frameCount++; + if (this.frameCount % 6 === 0) { + this.notifyUIUpdate(); + } + + // Continue loop + this.rafId = requestAnimationFrame(this.loop); + }; + + private update(deltaTime: number): void { + // Game logic goes here + // Will be filled in subsequent tasks + } + + private render(): void { + // Canvas rendering goes here + // Will be filled in subsequent tasks + } + + private notifyUIUpdate(): void { + if (this.uiUpdateCallback) { + this.uiUpdateCallback(this.getUIState()); + } + } + + private getUIState(): GameUIState { + return { + gameState: this.gameState, + storyPage: this.storyPage, + score: this.score, + lives: this.lives, + gameTime: this.formatGameTime(), + currentQuestion: this.getCurrentQuestion(), + }; + } + + // Public API for React to call + getCurrentState(): GameState { + return this.gameState; + } + + startStory(): void { + this.gameState = "story"; + this.storyPage = 1; + } + + nextStoryPage(): void { + if (this.storyPage < 7) { + this.storyPage++; + } else { + this.startPlaying(); + } + } + + startPlaying(): void { + this.gameState = "playing"; + this.gameStartTime = Date.now(); + // Initialize game state + } +} +``` + +--- + +### Task 2: Build SpriteAnimator System +- **What**: Create low-level animation controller for frame sequencing +- **Why**: Foundation for all sprite animation, reusable across games +- **Implementation notes**: + - Support individual frame images + - Support spritesheets (grid of frames) + - Named animation sequences + - FPS control, looping, callbacks + - Visual effects (tint, flip, alpha) + - Frame timing with deltaTime +- **Files affected**: + - New: `games/crash-course/engine/systems/SpriteAnimator.ts` +- **Acceptance criteria**: + - Can load individual frames or spritesheet + - Can define named animations + - Play/stop/pause animations + - Update with deltaTime + - Returns current frame image + - Effects work (tint, flip) + +**See full implementation in previous message - the complete SpriteAnimator class** + +--- + +### Task 3: Build AnimatedSprite Class +- **What**: High-level sprite entity combining position, size, and animation +- **Why**: Makes working with sprites easier, handles position + drawing +- **Implementation notes**: + - Wraps SpriteAnimator + - Tracks x, y, width, height + - Has layer property for z-ordering + - Convenience methods (play, update, draw) + - Returns bounding box for collision +- **Files affected**: + - New: `games/crash-course/engine/systems/AnimatedSprite.ts` +- **Acceptance criteria**: + - Combines animator with position + - Can update and draw itself + - Layer system works + - Bounding box for collision + +**See full implementation in previous message - the complete AnimatedSprite class** + +--- + +### Task 4: Build SpriteManager System +- **What**: Manages all animated sprites in the game with batch operations +- **Why**: Central registry for all sprites, batch update/draw, layer management +- **Implementation notes**: + - Create sprites with config + - Register sprites by ID + - Get sprites by ID or layer + - Batch update all sprites + - Batch draw with layer ordering + - Dynamic sprite creation/removal (for obstacles) +- **Files affected**: + - New: `games/crash-course/engine/systems/SpriteManager.ts` +- **Acceptance criteria**: + - Can create sprites from config + - Can retrieve sprites by ID + - updateAll() updates all sprites + - drawByLayers() draws in order + - Can add/remove sprites dynamically + - Layer system works + +**See full implementation in previous message - the complete SpriteManager class** + +--- + +### Task 5: Build RenderSystem +- **What**: Canvas drawing utilities and helpers +- **Why**: Reusable drawing operations, separate from game logic +- **Implementation notes**: + - Basic drawing primitives (rectangle, circle, text) + - Parallax background helpers + - Text rendering with styles + - Utility functions for common operations +- **Files affected**: + - New: `games/crash-course/engine/systems/RenderSystem.ts` +- **Acceptance criteria**: + - Helper functions for drawing + - Clean API + - Reusable across games + +**Example:** +```typescript +// games/crash-course/engine/systems/RenderSystem.ts + +export class RenderSystem { + constructor(private ctx: CanvasRenderingContext2D) {} + + clear(): void { + const canvas = this.ctx.canvas; + this.ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + drawRect( + x: number, + y: number, + width: number, + height: number, + color: string + ): void { + this.ctx.fillStyle = color; + this.ctx.fillRect(x, y, width, height); + } + + drawText( + text: string, + x: number, + y: number, + style: { + font?: string; + color?: string; + align?: CanvasTextAlign; + baseline?: CanvasTextBaseline; + } = {} + ): void { + this.ctx.save(); + + if (style.font) this.ctx.font = style.font; + if (style.color) this.ctx.fillStyle = style.color; + if (style.align) this.ctx.textAlign = style.align; + if (style.baseline) this.ctx.textBaseline = style.baseline; + + this.ctx.fillText(text, x, y); + + this.ctx.restore(); + } + + drawParallaxLayer( + image: HTMLImageElement, + offset: number, + y: number, + height: number + ): void { + const canvas = this.ctx.canvas; + const width = canvas.width; + + // Draw two copies for seamless looping + this.ctx.drawImage(image, -offset, y, width, height); + this.ctx.drawImage(image, width - offset, y, width, height); + } +} +``` + +--- + +### Task 6: Build AudioSystem +- **What**: Audio management with multiple tracks and volume control +- **Why**: Reusable audio handling, clean API +- **Implementation notes**: + - Load and manage multiple audio tracks + - Play/stop/pause functionality + - Volume control and muting + - Fade in/out + - Track transitions (tedox → neon owl) +- **Files affected**: + - New: `games/crash-course/engine/systems/AudioSystem.ts` +- **Acceptance criteria**: + - Can load multiple tracks + - Play/stop/pause works + - Volume and mute work + - Fade transitions work + - Handles autoplay restrictions + +**Example:** +```typescript +// games/crash-course/engine/systems/AudioSystem.ts + +export class AudioSystem { + private tracks = new Map(); + private currentTrack: string | null = null; + private isMuted: boolean = false; + private volume: number = 0.5; + + async loadTrack(name: string, url: string, loop: boolean = false): Promise { + const audio = new Audio(url); + audio.loop = loop; + audio.volume = this.volume; + this.tracks.set(name, audio); + } + + play(trackName: string): void { + const track = this.tracks.get(trackName); + if (!track) { + console.warn(`Track "${trackName}" not found`); + return; + } + + // Stop current track if different + if (this.currentTrack && this.currentTrack !== trackName) { + this.stop(this.currentTrack); + } + + this.currentTrack = trackName; + + if (!this.isMuted) { + track.currentTime = 0; + track.play().catch((error) => { + console.log(`Audio play failed: ${error}`); + }); + } + } + + stop(trackName: string): void { + const track = this.tracks.get(trackName); + if (track) { + track.pause(); + track.currentTime = 0; + } + } + + stopAll(): void { + for (const track of this.tracks.values()) { + track.pause(); + track.currentTime = 0; + } + this.currentTrack = null; + } + + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + for (const track of this.tracks.values()) { + track.volume = this.volume; + } + } + + mute(): void { + this.isMuted = true; + for (const track of this.tracks.values()) { + track.pause(); + } + } + + unmute(): void { + this.isMuted = false; + if (this.currentTrack) { + this.play(this.currentTrack); + } + } + + fadeOut(trackName: string, duration: number): void { + const track = this.tracks.get(trackName); + if (!track) return; + + const startVolume = track.volume; + const startTime = Date.now(); + + const fade = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + track.volume = startVolume * (1 - progress); + + if (progress < 1) { + requestAnimationFrame(fade); + } else { + this.stop(trackName); + track.volume = startVolume; // Reset volume + } + }; + + fade(); + } +} +``` + +--- + +### Task 7: Build AssetLoader System +- **What**: Load and manage all game assets (images, audio) +- **Why**: Centralized asset loading with progress tracking and error handling +- **Implementation notes**: + - Load images with progress tracking + - Load audio files + - Error handling and retries + - Progress callbacks for loading screen + - Return loaded assets as Map +- **Files affected**: + - New: `games/crash-course/engine/systems/AssetLoader.ts` +- **Acceptance criteria**: + - Can load multiple images + - Can load multiple audio files + - Progress tracking works + - Error handling works + - Returns organized assets + +**Example:** +```typescript +// games/crash-course/engine/systems/AssetLoader.ts + +type AssetManifest = { + images: {[key: string]: string}; + audio: {[key: string]: string}; +}; + +export class AssetLoader { + private images = new Map(); + private audio = new Map(); + + async loadAll( + manifest: AssetManifest, + onProgress?: (progress: number) => void + ): Promise { + const imageEntries = Object.entries(manifest.images); + const audioEntries = Object.entries(manifest.audio); + const total = imageEntries.length + audioEntries.length; + let loaded = 0; + + const updateProgress = () => { + loaded++; + if (onProgress) { + onProgress(loaded / total); + } + }; + + // Load images + const imagePromises = imageEntries.map(([key, url]) => + this.loadImage(url).then((img) => { + this.images.set(key, img); + updateProgress(); + }) + ); + + // Load audio + const audioPromises = audioEntries.map(([key, url]) => + this.loadAudio(url).then((audio) => { + this.audio.set(key, audio); + updateProgress(); + }) + ); + + await Promise.all([...imagePromises, ...audioPromises]); + } + + getImage(key: string): HTMLImageElement | undefined { + return this.images.get(key); + } + + getAudio(key: string): HTMLAudioElement | undefined { + return this.audio.get(key); + } + + private loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load image: ${url}`)); + img.src = url; + }); + } + + private loadAudio(url: string): Promise { + return new Promise((resolve, reject) => { + const audio = new Audio(); + audio.oncanplaythrough = () => resolve(audio); + audio.onerror = () => reject(new Error(`Failed to load audio: ${url}`)); + audio.src = url; + }); + } +} +``` + +--- + +### Task 8: Implement Perseus Integration Interface +- **What**: Define and implement the Perseus question integration interface +- **Why**: Standard pattern for educational games, reusable across games +- **Implementation notes**: + - Create PerseusGameEngine interface + - Implement in CrashCourseEngine + - getCurrentQuestion() for React to render + - submitAnswer() for React to call with result + - onQuestionChange callback for updates +- **Files affected**: + - New: `games/shared/perseus/PerseusGameEngine.ts` (create shared directory!) + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Interface defined and documented + - CrashCourseEngine implements interface + - React can get current question + - React can submit answers + - Callback system works + +**Example:** +```typescript +// games/shared/perseus/PerseusGameEngine.ts + +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Interface that all educational games must implement for Perseus integration. + * + * Provides a consistent way for games to integrate Perseus questions, + * regardless of game mechanics. + */ +export interface PerseusGameEngine { + /** + * Get the current question to display to the player. + * Returns null if no question is active. + */ + getCurrentQuestion(): PerseusItem | null; + + /** + * Called when the player submits an answer. + * + * @param correct - Whether the answer was correct + * @param earnedPoints - Points earned (from Perseus scoring) + */ + submitAnswer(correct: boolean, earnedPoints: number): void; + + /** + * Register callback for question state changes. + * Allows React to re-render when questions change. + */ + onQuestionChange(callback: (question: PerseusItem | null) => void): void; +} + +// CrashCourseEngine implements this +export class CrashCourseEngine implements PerseusGameEngine { + private currentObstacle: Obstacle | null = null; + private questionCallback: ((q: PerseusItem | null) => void) | null = null; + + getCurrentQuestion(): PerseusItem | null { + return this.currentObstacle?.question ?? null; + } + + submitAnswer(correct: boolean, earnedPoints: number): void { + if (!this.currentObstacle) return; + + this.currentObstacle.answered = true; + this.currentObstacle.correct = correct; + + if (correct) { + this.score += earnedPoints; + this.enterCoolMode(); + this.currentObstacle.jumped = true; // Trigger jump + } + + this.currentObstacle = null; + this.questionCallback?.(null); + } + + onQuestionChange(callback: (q: PerseusItem | null) => void): void { + this.questionCallback = callback; + } +} +``` + +--- + +### Task 9: Move Game State Machine to Engine +- **What**: Implement game state management in engine +- **Why**: Engine controls flow, React just displays current state +- **Implementation notes**: + - State: start, story, playing, carBonus, gameover, victory + - Story page tracking (1-7) + - Timer for victory condition (5 minutes) + - Public methods for state transitions +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - State machine works correctly + - Story pages advance + - Timer triggers victory + - React can query current state + +--- + +### Task 10: Move Physics and Collision to Engine +- **What**: Implement jump physics, obstacle movement, collision detection +- **Why**: Core game mechanics, needs to run at 60fps +- **Implementation notes**: + - Jump physics (parabolic arc) + - Obstacle movement (scrolling + racing away) + - Collision detection (rectangle collision) + - Life system + - Alien abduction rescue mechanic +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` + - New: `games/crash-course/engine/utils/physics.ts` + - New: `games/crash-course/engine/utils/collision.ts` +- **Acceptance criteria**: + - Jump feels right (same as original) + - Obstacles move correctly + - Collision detection works + - Life loss works + - Alien rescue works + +**Example physics utilities:** +```typescript +// games/crash-course/engine/utils/physics.ts + +export function calculateJumpY( + startTime: number, + currentTime: number, + groundY: number, + jumpHeight: number, + jumpDuration: number +): {y: number; isComplete: boolean} { + const elapsed = currentTime - startTime; + + if (elapsed >= jumpDuration) { + return {y: groundY, isComplete: true}; + } + + const progress = elapsed / jumpDuration; + const height = Math.sin(progress * Math.PI) * jumpHeight; + + return {y: groundY - height, isComplete: false}; +} + +// games/crash-course/engine/utils/collision.ts + +export function checkRectCollision( + x1: number, y1: number, w1: number, h1: number, + x2: number, y2: number, w2: number, h2: number +): boolean { + return x1 < x2 + w2 && x1 + w1 > x2 && + y1 < y2 + h2 && y1 + h1 > y2; +} +``` + +--- + +### Task 11: Migrate Character to Sprite System +- **What**: Create character sprite using SpriteManager +- **Why**: Test sprite system, clean up character rendering +- **Implementation notes**: + - Load character frames + - Define run, impact animations + - Cool mode uses tint effect + - Update position from physics + - Remove old character rendering code +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Character renders via sprite system + - Animations work (run, impact, cool mode) + - Cool mode tint works + - Position updates correctly + +--- + +### Task 12: Migrate Alien to Sprite System +- **What**: Create alien sprite with blinking animation +- **Why**: Test sprite system with callbacks +- **Implementation notes**: + - Load alien frames (3 frames) + - Define idle, blink animations + - Blink triggers periodically + - Floating motion updates position + - Abduction mode changes position +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Alien renders via sprite system + - Blinking works + - Floating motion works + - Abduction positioning works + +--- + +### Task 13: Migrate Obstacles to Sprite System +- **What**: Create car sprites dynamically as obstacles spawn +- **Why**: Test dynamic sprite creation/removal +- **Implementation notes**: + - When obstacle spawns, create sprite + - When obstacle leaves screen, remove sprite + - Car animation plays + - Collision uses sprite bounds +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Cars render via sprite system + - Sprites created/destroyed correctly + - Collision works with sprites + - No memory leaks (sprites properly removed) + +--- + +### Task 14: Move Parallax Background to Engine +- **What**: Implement parallax scrolling background +- **Why**: Visual effect, part of game rendering +- **Implementation notes**: + - 5 layers (sky, cityFar, citySemiFar, citySemiClose, cityClose) + - Different scroll speeds + - Seamless looping + - Use RenderSystem helpers +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Parallax scrolling works + - Seamless looping + - Performance good (60fps) + +--- + +### Task 15: Move Obstacle Spawning to Engine +- **What**: Spawn obstacles at intervals with Perseus questions +- **Why**: Core game mechanic +- **Implementation notes**: + - Spawn every 5 seconds + - Create Perseus question (from crash-course-utils) + - Create car sprite + - Track obstacles array + - Set as current question when close +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` + - Keep: `games/crash-course/crash-course-utils.ts` (question generation) +- **Acceptance criteria**: + - Obstacles spawn correctly + - Perseus questions attached + - Current question updates + - React receives question updates + +--- + +### Task 16: Integrate Audio with Game States +- **What**: Wire up AudioSystem to play correct music per game state +- **Why**: Audio is part of game experience +- **Implementation notes**: + - Menu music on start screen + - Game music during story/playing + - Tedox → Neon Owl transition + - Game over music + - Mute functionality +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Correct music plays per state + - Transitions work + - Mute works + - No audio errors + +--- + +### Task 17: Implement Timer and Victory Condition +- **What**: Track game time and trigger victory at 5 minutes +- **Why**: Win condition for the game +- **Implementation notes**: + - Track elapsed time from game start + - Format as game time (11:55:00 → 00:00:00) + - Trigger victory at 5 minutes + - Update UI state with formatted time +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - Timer counts up correctly + - Time displays correctly + - Victory triggers at 5 minutes + - React receives time updates + +--- + +### Task 18: Test Engine Independently +- **What**: Write unit tests for engine logic +- **Why**: Verify engine works without React +- **Implementation notes**: + - Test obstacle spawning + - Test collision detection + - Test state transitions + - Test Perseus integration + - Test physics calculations + - Mock canvas context +- **Files affected**: + - New: `games/crash-course/engine/CrashCourseEngine.test.ts` +- **Acceptance criteria**: + - Engine can run headless + - Unit tests pass + - Core mechanics tested + - No React dependencies in tests + +**Example test:** +```typescript +describe("CrashCourseEngine", () => { + let mockCanvas: HTMLCanvasElement; + let engine: CrashCourseEngine; + let uiUpdates: GameUIState[] = []; + + beforeEach(() => { + mockCanvas = document.createElement('canvas'); + engine = new CrashCourseEngine(mockCanvas, (state) => { + uiUpdates.push(state); + }); + }); + + it("starts in 'start' state", () => { + expect(engine.getCurrentState()).toBe("start"); + }); + + it("transitions through story pages", () => { + engine.startStory(); + expect(engine.getCurrentState()).toBe("story"); + + for (let i = 0; i < 7; i++) { + engine.nextStoryPage(); + } + + expect(engine.getCurrentState()).toBe("playing"); + }); + + it("spawns obstacles with Perseus questions", () => { + engine.startPlaying(); + + // Simulate 5 seconds + jest.advanceTimersByTime(5000); + + const question = engine.getCurrentQuestion(); + expect(question).not.toBeNull(); + expect(question).toHaveProperty('question'); + }); + + it("handles correct answer", () => { + engine.startPlaying(); + jest.advanceTimersByTime(5000); + + const initialScore = engine.getScore(); + engine.submitAnswer(true, 10); + + expect(engine.getScore()).toBe(initialScore + 10); + }); +}); +``` + +--- + +### Task 19: Create Engine Constants File +- **What**: Extract all magic numbers to constants +- **Why**: Centralized configuration, easier to tune +- **Implementation notes**: + - Canvas dimensions + - Physics constants (gravity, jump height, speed) + - Game config (duration, spawn interval) + - Sprite sizes +- **Files affected**: + - New: `games/crash-course/engine/constants.ts` +- **Acceptance criteria**: + - All magic numbers extracted + - Well-organized by category + - Documented with comments + +**Example:** +```typescript +// games/crash-course/engine/constants.ts + +export const CANVAS = { + WIDTH: 800, + HEIGHT: 600, +} as const; + +export const PHYSICS = { + GROUND_Y: 450, + SCROLL_SPEED: 2, + JUMP_HEIGHT: 140, + JUMP_DURATION: 1000, // milliseconds + GRAVITY: 0.5, +} as const; + +export const GAME_CONFIG = { + DURATION: 300000, // 5 minutes + OBSTACLE_SPAWN_INTERVAL: 5000, // 5 seconds + COOL_MODE_DURATION: 2000, // 2 seconds + LAMP_SPACING: 500, // pixels +} as const; + +export const SPRITE_SIZES = { + CHARACTER: 128, + ALIEN: 96, + CAR: 154, +} as const; + +export const CHARACTER = { + X: 100, + WIDTH: SPRITE_SIZES.CHARACTER, + HEIGHT: SPRITE_SIZES.CHARACTER, + COLLISION_ZONE_X: 120, +} as const; +``` + +--- + +### Task 20: Document Engine API +- **What**: Add JSDoc comments to public engine methods +- **Why**: Clear API for React component to use +- **Implementation notes**: + - Document constructor + - Document public methods (start, stop, startStory, etc.) + - Document callbacks + - Add usage examples +- **Files affected**: + - Modified: `games/crash-course/engine/CrashCourseEngine.ts` +- **Acceptance criteria**: + - All public methods documented + - Examples included + - Clear parameter descriptions + +--- + +## Directory Structure After This Phase + +``` +games/crash-course/ +├── engine/ +│ ├── CrashCourseEngine.ts (Main engine class) +│ ├── CrashCourseEngine.test.ts (Engine tests) +│ ├── types.ts (Engine-specific types) +│ ├── constants.ts (Game constants) +│ ├── systems/ +│ │ ├── SpriteAnimator.ts (Animation controller) +│ │ ├── AnimatedSprite.ts (Sprite entity) +│ │ ├── SpriteManager.ts (Multi-sprite management) +│ │ ├── RenderSystem.ts (Canvas utilities) +│ │ ├── AudioSystem.ts (Audio management) +│ │ └── AssetLoader.ts (Asset loading) +│ └── utils/ +│ ├── physics.ts (Physics calculations) +│ └── collision.ts (Collision detection) +├── assets/ (From Phase 1) +│ ├── sprites/ +│ ├── backgrounds/ +│ ├── ui/ +│ ├── audio/ +│ └── story/ +├── crash-course-utils.ts (Question generation - keep as is) +└── crash-course.stories.tsx (Will update in Phase 3) + +games/shared/ (NEW - shared across games) +└── perseus/ + └── PerseusGameEngine.ts (Interface for Perseus integration) +``` + +--- + +## Testing Considerations + +After this phase: +1. **Engine Tests**: Unit tests pass, engine runs headless +2. **Performance**: Measure FPS (should be 60fps stable) +3. **Memory**: Check for leaks (sprites properly cleaned up) +4. **Sprite System**: All entities render correctly +5. **Perseus Integration**: Questions appear and can be answered + +### How to Test Without React: + +```typescript +// Test engine in isolation +const canvas = document.createElement('canvas'); +const engine = new CrashCourseEngine(canvas, (state) => { + console.log("UI State:", state); +}); + +await engine.init(); +engine.start(); + +// Engine runs independently! +// Should see console logs of UI state updates +``` + +--- + +## Benefits After This Phase + +- ✅ **Game logic separate from React** - Clean architecture +- ✅ **60fps game loop** - Not affected by React rendering +- ✅ **Multi-entity sprite system** - Supports any number of animated sprites +- ✅ **Testable** - Can unit test engine without React +- ✅ **Reusable systems** - Sprite, audio, render systems ready to extract +- ✅ **Perseus integration** - Standard interface for educational games +- ✅ **Clear API** - React knows exactly how to communicate with engine + +--- + +## Next Phase + +**Phase 3**: Create thin React wrapper and UI components that connect to the engine via callbacks. + +The hard work is done - React becomes simple after this! 🚀 diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_3_ui_components.md b/packages/perseus/src/games/crash-course/plans/PHASE_3_ui_components.md new file mode 100644 index 00000000000..d5542f53be0 --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_3_ui_components.md @@ -0,0 +1,800 @@ +# Phase 3: Extract UI Components & Wire Up + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Create React components for UI overlays and connect them to the game engine. + +**Estimated Effort**: 2-3 hours + +## Overview + +Now that the engine is working, we build the React wrapper. This phase is much simpler than the original plan because the engine handles all game logic. React becomes a thin UI layer that: +- Renders canvas for the engine +- Shows UI overlays (screens, HUD, questions) +- Calls engine methods in response to user actions + +The engine runs at 60fps, React updates at 10-15fps when UI state changes. + +## Tasks + +### Task 1: Create Main Component Wrapper +- **What**: Build the main CrashCourseGame component that wraps the engine +- **Why**: Connects React to the engine +- **Implementation notes**: + - Create ref for canvas element + - Create engine instance in useEffect + - Initialize engine and start it + - Listen for UI state updates + - Clean up on unmount + - Render canvas + UI overlays based on state +- **Files affected**: + - New: `games/crash-course/CrashCourseGame.tsx` (replaces .stories.tsx main component) + - Modified: `games/crash-course/crash-course.stories.tsx` (just exports the component now) +- **Acceptance criteria**: + - Engine initializes and starts + - Canvas renders + - UI state updates trigger re-renders + - Engine stops on unmount + - No memory leaks + +**Example structure:** +```typescript +// games/crash-course/CrashCourseGame.tsx + +import * as React from "react"; +import {useEffect, useRef, useState} from "react"; +import {View} from "@khanacademy/wonder-blocks-core"; + +import {CrashCourseEngine} from "./engine/CrashCourseEngine"; +import type {GameUIState} from "./engine/types"; +import styles from "./crash-course.module.css"; + +// UI components (will create in subsequent tasks) +import {StartScreen} from "./components/StartScreen"; +import {StoryScreen} from "./components/StoryScreen"; +import {HUD} from "./components/HUD"; +import {QuestionOverlay} from "./components/QuestionOverlay"; +import {VictoryScreen} from "./components/VictoryScreen"; +import {GameOverScreen} from "./components/GameOverScreen"; +import {MuteButton} from "./components/MuteButton"; +import {BenevolenceMessage} from "./components/BenevolenceMessage"; +import {CarBonusScene} from "./car-bonus-scene"; + +/** + * Crash Course - An endless runner educational game. + * + * Integrates Perseus widgets with game mechanics. + * Players answer math questions to jump over obstacles. + */ +export const CrashCourseGame = (): React.ReactElement => { + const canvasRef = useRef(null); + const engineRef = useRef(null); + + const [uiState, setUIState] = useState(null); + const [isMuted, setIsMuted] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Initialize engine + useEffect(() => { + if (!canvasRef.current) return; + + const engine = new CrashCourseEngine({ + canvas: canvasRef.current, + onUIUpdate: setUIState, + muted: isMuted, + }); + + engineRef.current = engine; + + // Initialize and start engine + engine.init().then(() => { + setIsLoading(false); + engine.start(); + }); + + // Cleanup + return () => { + engine.stop(); + engineRef.current = null; + }; + }, []); + + // Handle mute toggle + const handleToggleMute = () => { + setIsMuted(!isMuted); + engineRef.current?.toggleMute(); + }; + + // Handle answer submission from QuestionOverlay + const handleCheckAnswer = (correct: boolean, points: number) => { + engineRef.current?.submitAnswer(correct, points); + }; + + if (isLoading) { + return
Loading...
; + } + + if (!uiState) { + return
Initializing...
; + } + + return ( + +
+ {/* Canvas - engine draws here */} + + + {/* Mute button - always visible */} + + + {/* Start screen */} + {uiState.gameState === "start" && ( + engineRef.current?.startStory()} + /> + )} + + {/* Story screen */} + {uiState.gameState === "story" && ( + engineRef.current?.nextStoryPage()} + /> + )} + + {/* Playing state - HUD and question */} + {uiState.gameState === "playing" && ( + <> + + + {uiState.currentQuestion && ( + + )} + + {uiState.showBenevolence && } + + )} + + {/* Car bonus scene */} + {uiState.gameState === "carBonus" && ( + { + // Engine will transition to gameover + }} + /> + )} + + {/* Victory screen */} + {uiState.gameState === "victory" && ( + engineRef.current?.startStory()} + /> + )} + + {/* Game over screen */} + {uiState.gameState === "gameover" && ( + engineRef.current?.startStory()} + /> + )} +
+
+ ); +}; +``` + +--- + +### Task 2: Create StartScreen Component +- **What**: Title screen with start button +- **Why**: Clean separation of UI concerns +- **Implementation notes**: + - Display title image + - Show start button + - Handle click to start + - Use existing CSS styles +- **Files affected**: + - New: `games/crash-course/components/StartScreen.tsx` +- **Acceptance criteria**: + - Renders title correctly + - Start button triggers callback + - Styling preserved + +**Example:** +```typescript +// games/crash-course/components/StartScreen.tsx + +import * as React from "react"; +import titleImg from "../assets/ui/title.png"; +import startImg from "../assets/ui/start.png"; +import styles from "../crash-course.module.css"; + +type StartScreenProps = { + onStart: () => void; +}; + +export const StartScreen = ({onStart}: StartScreenProps): React.ReactElement => { + return ( +
+ Crash Course + Start Game +
+ ); +}; +``` + +--- + +### Task 3: Create StoryScreen Component +- **What**: Story page viewer with navigation +- **Why**: Separates story UI from game logic +- **Implementation notes**: + - Display current story page image + - Show next button (or start button on page 7) + - Handle navigation + - Use existing CSS styles +- **Files affected**: + - New: `games/crash-course/components/StoryScreen.tsx` +- **Acceptance criteria**: + - Shows correct story page + - Next button works + - Final page shows start button + - Images load correctly + +**Example:** +```typescript +// games/crash-course/components/StoryScreen.tsx + +import * as React from "react"; +import styles from "../crash-course.module.css"; + +// Import all story images +import story1Img from "../assets/story/story1.png"; +import story2Img from "../assets/story/story2.png"; +import story3Img from "../assets/story/story3.png"; +import story4Img from "../assets/story/story4.png"; +import story5Img from "../assets/story/story5.png"; +import story6Img from "../assets/story/story6.png"; +import story7Img from "../assets/story/story7.png"; +import nextImg from "../assets/ui/next.png"; +import startImg from "../assets/ui/start.png"; + +const STORY_IMAGES = [ + story1Img, + story2Img, + story3Img, + story4Img, + story5Img, + story6Img, + story7Img, +]; + +type StoryScreenProps = { + page: number; // 1-7 + onNext: () => void; +}; + +export const StoryScreen = ({page, onNext}: StoryScreenProps): React.ReactElement => { + const storyImg = STORY_IMAGES[page - 1]; + const isLastPage = page === 7; + const buttonImg = isLastPage ? startImg : nextImg; + const buttonAlt = isLastPage ? "Start" : "Next"; + + return ( +
+ {`Story + {buttonAlt} +
+ ); +}; +``` + +--- + +### Task 4: Create HUD Component +- **What**: Heads-up display showing score, time, lives +- **Why**: Clean UI component for gameplay info +- **Implementation notes**: + - Display score + - Display formatted game time + - Display lives as alien emojis + - Background gradient + - Use existing CSS styles +- **Files affected**: + - New: `games/crash-course/components/HUD.tsx` +- **Acceptance criteria**: + - All info displays correctly + - Lives show/fade appropriately + - Background gradient applies + - Positioning correct + +**Example:** +```typescript +// games/crash-course/components/HUD.tsx + +import * as React from "react"; +import styles from "../crash-course.module.css"; + +type HUDProps = { + score: number; + lives: number; + gameTime: string; +}; + +export const HUD = ({score, lives, gameTime}: HUDProps): React.ReactElement => { + return ( + <> +
+
+
{gameTime}
+ +
Score: {score}
+ +
+ Alien Benevolence: + 0 ? styles.alien : `${styles.alien} ${styles.alienLost}`}> + 👽 + + 1 ? styles.alien : `${styles.alien} ${styles.alienLost}`}> + 👽 + + 2 ? styles.alien : `${styles.alien} ${styles.alienLost}`}> + 👽 + +
+
+ + ); +}; +``` + +--- + +### Task 5: Create QuestionOverlay Component +- **What**: Perseus question renderer with timer and check button +- **Why**: Handles Perseus widget integration +- **Implementation notes**: + - Render Perseus question using ServerItemRenderer + - Timer bar (visual only, engine tracks actual time) + - Check answer button + - Answer feedback display + - Score answer using Perseus scoring + - Pass result to engine +- **Files affected**: + - New: `games/crash-course/components/QuestionOverlay.tsx` +- **Acceptance criteria**: + - Perseus widget renders + - Check answer works + - Scoring works + - Feedback displays + - Engine receives result + +**Example:** +```typescript +// games/crash-course/components/QuestionOverlay.tsx + +import * as React from "react"; +import {useRef, useState} from "react"; +import {scorePerseusItem} from "@khanacademy/perseus-score"; +import {ServerItemRenderer} from "../server-item-renderer"; +import {storybookDependenciesV2} from "../../../../testing/test-dependencies"; + +import type {PerseusItem} from "@khanacademy/perseus-core"; +import styles from "../crash-course.module.css"; + +type QuestionOverlayProps = { + question: PerseusItem; + onCheckAnswer: (correct: boolean, points: number) => void; +}; + +export const QuestionOverlay = ({ + question, + onCheckAnswer, +}: QuestionOverlayProps): React.ReactElement => { + const itemRendererRef = useRef(null); + const [feedback, setFeedback] = useState<{ + show: boolean; + correct: boolean; + message: string; + }>({show: false, correct: false, message: ""}); + + const handleCheck = () => { + const userInput = itemRendererRef.current?.getUserInput(); + if (!userInput) return; + + try { + const result = scorePerseusItem(question.question, userInput, "en"); + + const isCorrect = + result.type === "points" && + result.earned === result.total && + result.earned > 0; + + const points = result.type === "points" ? result.earned : 0; + + if (isCorrect) { + setFeedback({ + show: true, + correct: true, + message: "Correct! 🎉", + }); + + // Notify engine + onCheckAnswer(true, points); + + // Clear feedback after delay + setTimeout(() => { + setFeedback({show: false, correct: false, message: ""}); + }, 1000); + } else { + setFeedback({ + show: true, + correct: false, + message: + result.type === "invalid" + ? "Please enter an answer!" + : "Try again! You can keep answering until you get it right!", + }); + } + } catch (error) { + console.error("Error scoring answer:", error); + } + }; + + // Handle Enter key + React.useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCheck(); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, []); + + return ( +
+ + + + + {feedback.show && ( +
+ {feedback.message} +
+ )} +
+ ); +}; +``` + +--- + +### Task 6: Create VictoryScreen Component +- **What**: Victory screen with play again button +- **Why**: Clean victory UI +- **Implementation notes**: + - Display victory image + - Show final score + - Play again button + - Use existing CSS styles +- **Files affected**: + - New: `games/crash-course/components/VictoryScreen.tsx` +- **Acceptance criteria**: + - Victory image displays + - Score displays + - Play again works + +--- + +### Task 7: Create GameOverScreen Component +- **What**: Game over screen with play again button +- **Why**: Clean game over UI +- **Implementation notes**: + - Display lose image + - Show final score + - Play again button + - Use existing CSS styles +- **Files affected**: + - New: `games/crash-course/components/GameOverScreen.tsx` +- **Acceptance criteria**: + - Game over image displays + - Score displays + - Play again works + +--- + +### Task 8: Create MuteButton Component +- **What**: Mute/unmute toggle button +- **Why**: Simple reusable component +- **Implementation notes**: + - Display mute or unmute icon + - Toggle on click + - Always visible +- **Files affected**: + - New: `games/crash-course/components/MuteButton.tsx` +- **Acceptance criteria**: + - Correct icon shows + - Click toggles mute + - Positioning preserved + +**Example:** +```typescript +// games/crash-course/components/MuteButton.tsx + +import * as React from "react"; +import muteImg from "../assets/ui/mute.png"; +import unmuteImg from "../assets/ui/unmute.png"; +import styles from "../crash-course.module.css"; + +type MuteButtonProps = { + isMuted: boolean; + onToggle: () => void; +}; + +export const MuteButton = ({isMuted, onToggle}: MuteButtonProps): React.ReactElement => { + return ( + {isMuted + ); +}; +``` + +--- + +### Task 9: Create BenevolenceMessage Component +- **What**: "BENEVOLENCE" message animation +- **Why**: Shows when alien saves player +- **Implementation notes**: + - Display text with animation + - Auto-hide after 2 seconds + - Use CSS animation +- **Files affected**: + - New: `games/crash-course/components/BenevolenceMessage.tsx` +- **Acceptance criteria**: + - Message displays + - Animation plays + - Auto-hides + +**Example:** +```typescript +// games/crash-course/components/BenevolenceMessage.tsx + +import * as React from "react"; +import styles from "../crash-course.module.css"; + +export const BenevolenceMessage = (): React.ReactElement => { + return
BENEVOLENCE
; +}; +``` + +--- + +### Task 10: Create Components Index +- **What**: Barrel export for all components +- **Why**: Clean imports in main component +- **Implementation notes**: + - Export all components from index.ts + - Simplifies imports +- **Files affected**: + - New: `games/crash-course/components/index.ts` +- **Acceptance criteria**: + - All components exported + - Can import from components/ + +**Example:** +```typescript +// games/crash-course/components/index.ts + +export {StartScreen} from "./StartScreen"; +export {StoryScreen} from "./StoryScreen"; +export {HUD} from "./HUD"; +export {QuestionOverlay} from "./QuestionOverlay"; +export {VictoryScreen} from "./VictoryScreen"; +export {GameOverScreen} from "./GameOverScreen"; +export {MuteButton} from "./MuteButton"; +export {BenevolenceMessage} from "./BenevolenceMessage"; +``` + +--- + +### Task 11: Update Storybook Story +- **What**: Update story file to use new component +- **Why**: Keep Storybook working +- **Implementation notes**: + - Import CrashCourseGame component + - Export as default story + - Update metadata if needed +- **Files affected**: + - Modified: `games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story exports component + - Works in Storybook + - Title is "Games/Crash Course" + +**Example:** +```typescript +// games/crash-course/crash-course.stories.tsx + +import type {Meta, StoryObj} from "@storybook/react-vite"; +import {CrashCourseGame} from "./CrashCourseGame"; + +const meta: Meta = { + title: "Games/Crash Course", + component: CrashCourseGame, + parameters: { + docs: { + description: { + component: "Educational endless runner with Perseus question integration.", + }, + }, + layout: "fullscreen", + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; +``` + +--- + +### Task 12: Test Full Game Flow +- **What**: Play through entire game to verify everything works +- **Why**: Integration testing +- **Implementation notes**: + - Start game + - Go through story + - Play game + - Answer questions + - Verify all screens + - Check audio + - Test mute +- **Files affected**: + - N/A (testing task) +- **Acceptance criteria**: + - Full game flow works + - All screens display + - Questions work + - Audio works + - No console errors + - Game plays identically to original + +--- + +### Task 13: Remove Old Files (Optional) +- **What**: Remove original files from __docs__ now that new version works +- **Why**: Clean up, avoid confusion +- **Implementation notes**: + - **ONLY do this if new version is fully working** + - Delete math-blaster files from __docs__ + - Keep one commit that can be reverted if needed +- **Files affected**: + - Deleted: `__docs__/math-blaster-game.stories.tsx` + - Deleted: `__docs__/math-blaster-utils.ts` + - Deleted: `__docs__/math-blaster-game.module.css` + - Deleted: (assets already moved in Phase 1) +- **Acceptance criteria**: + - Old files removed + - New version works + - Can rollback if needed + +--- + +## Component Structure After This Phase + +``` +games/crash-course/ +├── components/ +│ ├── StartScreen.tsx (~30 lines) +│ ├── StoryScreen.tsx (~40 lines) +│ ├── HUD.tsx (~40 lines) +│ ├── QuestionOverlay.tsx (~100 lines) +│ ├── VictoryScreen.tsx (~30 lines) +│ ├── GameOverScreen.tsx (~30 lines) +│ ├── MuteButton.tsx (~20 lines) +│ ├── BenevolenceMessage.tsx (~10 lines) +│ └── index.ts (~10 lines) +├── CrashCourseGame.tsx (~150 lines) +├── crash-course.stories.tsx (~20 lines) +└── ... (engine files from Phase 2) +``` + +--- + +## Testing Considerations + +After this phase: +1. **Visual Testing**: All screens display correctly +2. **Interaction Testing**: Buttons and input work +3. **Perseus Integration**: Questions render and score correctly +4. **Audio**: Music plays, transitions work, mute works +5. **Performance**: Still 60fps, no jank +6. **Baseline Tests**: Compare against Phase 0 tests + +--- + +## Benefits After This Phase + +- ✅ **Clean React components** - Each screen is separate +- ✅ **Thin wrapper** - Main component is < 200 lines +- ✅ **Perseus integration works** - Questions render and score +- ✅ **Game fully playable** - All functionality restored +- ✅ **Performance good** - 60fps maintained +- ✅ **Maintainable** - Clear separation of concerns + +--- + +## Next Phase + +**Phase 4**: Add documentation, verify tests pass, finalize everything. + +The refactoring is almost complete! 🎉 diff --git a/packages/perseus/src/games/crash-course/plans/PHASE_4_documentation_polish.md b/packages/perseus/src/games/crash-course/plans/PHASE_4_documentation_polish.md new file mode 100644 index 00000000000..9a26b7fa87a --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/PHASE_4_documentation_polish.md @@ -0,0 +1,891 @@ +# Phase 4: Documentation & Polish + +**Part of**: [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) + +**Goal**: Document the game architecture, add code comments, verify everything works correctly, and polish the implementation. + +## Tasks + +### Task 1: Write Main README +- **What**: Create comprehensive README for the Crash Course game +- **Why**: Help developers understand the architecture and how to modify/extend the game +- **Implementation notes**: + - Explain the game engine architecture + - Document the React ↔ Engine communication pattern + - Provide examples of common modifications + - Include screenshots/diagrams + - Link to related documentation +- **Files affected**: + - New: `packages/perseus/src/games/crash-course/README.md` +- **Acceptance criteria**: + - Architecture clearly explained + - Common tasks documented (add sprite, change mechanics, etc.) + - Diagrams showing component relationships + - Code examples for extending the game + +**README structure:** +```markdown +# Crash Course - Educational Endless Runner Game + +## Overview +Educational endless runner game where players answer math questions while avoiding obstacles... + +## Architecture + +### Game Engine Pattern +``` +CrashCourseEngine (60fps) React UI (10-15fps) +├── Game Loop ├── Canvas wrapper +├── Physics & Collision ├── UI Screens +├── Sprite Animation ├── HUD +└── Perseus Integration └── Question Overlay +``` + +### Why This Architecture? +- **Separation of concerns**: Game logic in pure TypeScript, UI in React +- **Performance**: 60fps game loop independent of React rendering +- **Testability**: Engine can be unit tested without React +- **Reusability**: Systems can be shared across games + +## Key Systems + +### CrashCourseEngine +Main game engine class... + +### SpriteManager +Handles multi-entity sprite animations... + +### RenderSystem +Canvas drawing utilities... + +### AudioSystem +Music and sound effect management... + +### Perseus Integration +How the game presents questions and handles answers... + +## Common Tasks + +### Adding a New Sprite +```typescript +await spriteManager.create("my-sprite", { + frames: [sprite1, sprite2, sprite3], + animations: { + idle: {frames: [0], loop: true}, + action: {frames: [1, 2], fps: 10, loop: false}, + }, + position: {x: 100, y: 200}, + size: {width: 50, height: 50}, +}); +``` + +### Modifying Game Mechanics +To change obstacle spawn rate... +To add a new power-up... + +### Adding New Question Types +Questions are generated in `crash-course-utils.ts`... + +## File Structure +``` +games/crash-course/ +├── engine/ # Game engine core +├── systems/ # Reusable systems +├── sprites/ # Sprite animation +├── components/ # React UI components +└── assets/ # Images and audio +``` + +## Testing +- Run tests: `pnpm --filter perseus test games/crash-course` +- See baseline tests in `__tests__/crash-course.test.tsx` + +## Performance +- Target: 60fps game loop, 10-15fps UI updates +- See PERFORMANCE_BASELINE.md for metrics + +## Future Extensions +- This pattern works for other game types (platformers, puzzles, etc.) +- Reusable systems in `games/shared/` when game #2 needs them +``` + +### Task 2: Document Engine API +- **What**: Add comprehensive JSDoc comments to engine classes +- **Why**: Make the engine API self-documenting for future developers +- **Implementation notes**: + - Document all public methods with JSDoc + - Explain parameters, return values, and side effects + - Add usage examples in comments + - Document the lifecycle (init → start → update → render → stop) +- **Files affected**: + - `packages/perseus/src/games/crash-course/engine/crash-course-engine.ts` + - `packages/perseus/src/games/crash-course/engine/game-types.ts` +- **Acceptance criteria**: + - All public APIs have JSDoc comments + - Examples provided for complex methods + - Lifecycle clearly documented + - IDE autocomplete shows helpful documentation + +**Example JSDoc:** +```typescript +/** + * Main game engine for Crash Course. + * + * Runs a 60fps game loop independent of React, handling physics, collision detection, + * sprite animation, and Perseus question integration. + * + * @example + * ```typescript + * const engine = new CrashCourseEngine({ + * canvas: canvasElement, + * onUIUpdate: (state) => setGameState(state), + * }); + * + * await engine.init(); // Load assets + * engine.start(); // Begin game loop + * // Later... + * engine.stop(); // Clean up + * ``` + */ +export class CrashCourseEngine { + /** + * Initialize the game engine and load all assets. + * + * Must be called before start(). Loads sprites, audio, and sets up systems. + * + * @returns Promise that resolves when all assets are loaded + * @throws Error if canvas is not available or assets fail to load + */ + async init(): Promise { /* ... */ } + + /** + * Start the game loop. + * + * Begins the 60fps requestAnimationFrame loop. Call after init() completes. + * The game will begin in the "start" state showing the start screen. + */ + start(): void { /* ... */ } + + /** + * Submit an answer to the current Perseus question. + * + * @param correct - Whether the answer was correct + * @param earnedPoints - Points earned (usually 1 for correct, 0 for incorrect) + */ + submitAnswer(correct: boolean, earnedPoints: number): void { /* ... */ } +} +``` + +### Task 3: Document Systems +- **What**: Add documentation for each system (Render, Audio, Assets, Sprites) +- **Why**: Explain how systems work and how to use/extend them +- **Implementation notes**: + - JSDoc comments for all public methods + - README in systems/ folder explaining system architecture + - Document design patterns used + - Explain how systems interact +- **Files affected**: + - `packages/perseus/src/games/crash-course/systems/render-system.ts` + - `packages/perseus/src/games/crash-course/systems/audio-system.ts` + - `packages/perseus/src/games/crash-course/systems/asset-loader.ts` + - New: `packages/perseus/src/games/crash-course/systems/README.md` +- **Acceptance criteria**: + - All systems have JSDoc comments + - Systems README explains overall architecture + - Design patterns documented + - Examples of extending systems + +**Systems README structure:** +```markdown +# Game Systems + +Reusable systems for game functionality. + +## RenderSystem +Handles canvas drawing operations... + +## AudioSystem +Manages music tracks and sound effects... + +## AssetLoader +Loads and caches images and audio files... + +## Design Patterns +- **Dependency Injection**: Systems receive dependencies in constructor +- **Single Responsibility**: Each system handles one concern +- **Composition over Inheritance**: Engine composes systems rather than inheriting + +## Future Reusability +When building game #2, these systems can be moved to `games/shared/systems/`... +``` + +### Task 4: Document Sprite System +- **What**: Document the sprite animation system in detail +- **Why**: This is a reusable system that future games will use +- **Implementation notes**: + - Comprehensive JSDoc for SpriteAnimator, AnimatedSprite, SpriteManager + - Document animation configuration format + - Provide examples for common use cases + - Explain layer-based rendering + - Document sprite effects (shake, opacity, etc.) +- **Files affected**: + - `packages/perseus/src/games/crash-course/sprites/sprite-animator.ts` + - `packages/perseus/src/games/crash-course/sprites/animated-sprite.ts` + - `packages/perseus/src/games/crash-course/sprites/sprite-manager.ts` + - New: `packages/perseus/src/games/crash-course/sprites/README.md` +- **Acceptance criteria**: + - All classes fully documented + - Animation config format explained + - Examples for: basic sprite, multi-animation sprite, effects, layers + - Performance considerations documented + +**Sprite README structure:** +```markdown +# Sprite Animation System + +Multi-entity sprite animation system for 2D games. + +## Architecture + +``` +SpriteManager +├── AnimatedSprite #1 +│ └── SpriteAnimator +├── AnimatedSprite #2 +│ └── SpriteAnimator +└── AnimatedSprite #3 + └── SpriteAnimator +``` + +## Quick Start + +### Single Sprite +```typescript +const animator = new SpriteAnimator(); +await animator.loadFrames([img1, img2, img3]); +animator.addAnimation("walk", [0, 1, 2], {fps: 10, loop: true}); +animator.play("walk"); +``` + +### Multiple Sprites (Manager) +```typescript +const manager = new SpriteManager(assetLoader); + +await manager.create("player", { + frames: ["run1.png", "run2.png"], + animations: {run: {frames: [0, 1], fps: 8}}, +}); + +await manager.create("enemy", { + frames: ["alien1.png", "alien2.png"], + animations: {float: {frames: [0, 1], fps: 4}}, +}); + +// In game loop +manager.updateAll(deltaTime); +manager.drawByLayers(ctx, ["background", "entities", "effects"]); +``` + +## Animation Configuration +```typescript +type AnimationConfig = { + frames: number[]; // Frame indices + fps?: number; // Default: 8 + loop?: boolean; // Default: true + onComplete?: () => void; +}; +``` + +## Effects +- Shake effect: `sprite.animator.setEffect({type: "shake", intensity: 5})` +- Opacity: `sprite.opacity = 0.5` + +## Layer-Based Rendering +Sprites can be assigned to layers for draw order control... + +## Performance +- Spritesheets recommended for many frames +- Limit active sprites to ~50 for 60fps +- Use object pooling for frequently spawned sprites + +## Future Games +This system can be moved to `games/shared/sprites/` when needed by other games. +``` + +### Task 5: Document React Components +- **What**: Add JSDoc comments and prop documentation to React components +- **Why**: Make UI components easy to understand and modify +- **Implementation notes**: + - Document all component props with TypeScript + - Add JSDoc comments explaining component purpose + - Document callback patterns + - Explain when components render +- **Files affected**: + - All files in `packages/perseus/src/games/crash-course/components/` +- **Acceptance criteria**: + - All props have TypeScript types and descriptions + - Component purpose documented + - Rendering behavior explained + - Examples for complex components + +**Example component documentation:** +```typescript +/** + * Displays the current score, lives, and time remaining during gameplay. + * + * Updates 10-15 times per second via throttled callbacks from the game engine. + * + * @param score - Current player score (0-999+) + * @param lives - Remaining lives (0-3) + * @param gameTime - Formatted time string (e.g., "4:23") + */ +export function HUD({ + score, + lives, + gameTime, +}: { + score: number; + lives: number; + gameTime: string; +}): React.ReactElement { + /* ... */ +} +``` + +### Task 6: Document Perseus Integration +- **What**: Document how Perseus questions are integrated +- **Why**: This pattern will be reused by future educational games +- **Implementation notes**: + - Document the PerseusGameEngine interface implementation + - Explain question lifecycle (spawn → present → answer → despawn) + - Document how to add new question types + - Explain answer validation flow +- **Files affected**: + - `packages/perseus/src/games/crash-course/engine/crash-course-engine.ts` (Perseus methods) + - `packages/perseus/src/games/crash-course/utils/crash-course-utils.ts` + - `packages/perseus/src/games/shared/perseus/PerseusGameEngine.ts` +- **Acceptance criteria**: + - PerseusGameEngine interface documented + - Question lifecycle explained + - Examples for adding question types + - Answer validation flow documented + +**Perseus integration docs:** +```typescript +/** + * Perseus integration interface for educational games. + * + * All games that integrate Perseus questions should implement this interface + * to ensure consistent behavior. + */ +export interface PerseusGameEngine { + /** + * Get the currently active Perseus question. + * + * @returns The current question, or null if no question is active + */ + getCurrentQuestion(): PerseusItem | null; + + /** + * Submit an answer to the current question. + * + * @param correct - Whether the answer was correct + * @param earnedPoints - Points earned (typically 1 for correct, 0 for incorrect) + */ + submitAnswer(correct: boolean, earnedPoints: number): void; + + /** + * Register a callback for when the question changes. + * + * The game should call this callback when: + * - A new question is presented + * - A question is answered and despawned + * - The game ends + * + * @param callback - Function to call with the new question (or null) + */ + onQuestionChange(callback: (question: PerseusItem | null) => void): void; +} +``` + +### Task 7: Add Inline Code Comments +- **What**: Add explanatory comments for complex logic +- **Why**: Make the code easier to understand and modify +- **Implementation notes**: + - Comment complex algorithms (collision detection, spawning logic) + - Explain non-obvious choices (magic numbers, timing values) + - Document edge cases and their handling + - Add TODO comments for future improvements +- **Files affected**: + - All TypeScript files with complex logic +- **Acceptance criteria**: + - Complex algorithms have explanatory comments + - Magic numbers explained (e.g., why SPAWN_INTERVAL = 5000) + - Edge cases documented + - Future improvements noted with TODO comments + +**Example inline comments:** +```typescript +// Check if obstacle is in the collision zone AND player hasn't answered the question +// The collision zone starts before the character position to give time to answer +if ( + obstacle.x < COLLISION_ZONE_X && // Obstacle has entered the zone + obstacle.x + OBSTACLE_WIDTH > characterX && // Still overlapping + !obstacle.answered // Question hasn't been answered +) { + // Trigger question presentation + this.currentQuestion = obstacle.question; + this.notifyQuestionChange(); +} + +// Game speed increases every 30 seconds to add difficulty +// Caps at 2x base speed to keep game playable +if (gameTimeSeconds % 30 === 0 && this.scrollSpeed < SCROLL_SPEED * 2) { + this.scrollSpeed += 0.2; // 10% increase +} +``` + +### Task 8: Create Migration Guide +- **What**: Document the refactoring changes for reviewers +- **Why**: Help code reviewers understand what changed and why +- **Implementation notes**: + - Before/after architecture comparison + - List of moved files + - Breaking changes (if any) + - Testing strategy explanation +- **Files affected**: + - New: `MIGRATION.md` (in repo root, temporary) +- **Acceptance criteria**: + - Clear before/after comparison + - All file moves documented + - Testing approach explained + - Can be removed after merge + +**Migration guide structure:** +```markdown +# Crash Course Refactoring - Migration Guide + +## What Changed + +### Architecture +**Before**: Monolithic React component with game loop in useEffect +**After**: Custom game engine (pure TypeScript) + thin React UI wrapper + +### File Organization +**Before**: All files in `__docs__/` +**After**: Organized in `games/crash-course/` + +## Moved Files + +| Old Location | New Location | +|--------------|--------------| +| `__docs__/math-blaster-game.stories.tsx` | `games/crash-course/crash-course.stories.tsx` | +| `__docs__/math-blaster-utils.ts` | `games/crash-course/utils/crash-course-utils.ts` | +| `__docs__/run1.png` ... | `games/crash-course/assets/sprites/character/` | + +## Breaking Changes +- **None** - This is an internal refactoring, no public API changes + +## Testing Strategy +- Phase 0 established baseline tests before refactoring +- All tests passing with new architecture +- Performance metrics verified (see PERFORMANCE_BASELINE.md) + +## Review Checklist +- [ ] Architecture makes sense (engine vs React separation) +- [ ] File organization is clear +- [ ] Tests cover critical functionality +- [ ] Performance is maintained or improved +- [ ] Code is well-documented +``` + +### Task 9: Run Full Test Suite +- **What**: Run all tests and verify they pass +- **Why**: Ensure refactoring didn't break anything +- **Implementation notes**: + - Run: `pnpm --filter perseus test games/crash-course` + - Verify all baseline tests pass + - Check for any test warnings + - Fix any failing tests +- **Files affected**: + - Test files in `packages/perseus/src/games/crash-course/__tests__/` +- **Acceptance criteria**: + - All tests passing + - No test warnings + - Code coverage acceptable (>80% for engine core) + +### Task 10: Verify Performance Baselines +- **What**: Compare current performance to Phase 0 baselines +- **Why**: Ensure refactoring maintained or improved performance +- **Implementation notes**: + - Use Chrome DevTools Performance tab + - Record 30-second gameplay session + - Compare FPS, memory, load time to PERFORMANCE_BASELINE.md + - Document any improvements or regressions +- **Files affected**: + - Update: `PERFORMANCE_BASELINE.md` +- **Acceptance criteria**: + - FPS maintained or improved (target: 58-60fps) + - Memory usage stable (no leaks) + - Load time comparable or better + - Findings documented + +**Performance comparison format:** +```markdown +## Phase 4 Results (After Refactoring) + +### Frame Rate +- Average FPS: 59-60 fps ✅ (was: 58-60) +- Minimum FPS: 57 fps ✅ (was: 55) +- Frame time: 16-17ms ✅ (unchanged) + +### Load Time +- Asset loading: 1.8 seconds ✅ (was: 2.1s, improved!) +- Game initialization: 280ms ✅ (was: 320ms) +- Time to interactive: 2.1 seconds ✅ (was: 2.5s) + +### Memory +- Initial: 42MB ✅ (was: 45MB, slightly better) +- After 1 minute: 48MB ✅ (was: 52MB) +- After 5 minutes: 52MB ✅ (was: 58MB) +- Memory more stable with engine architecture + +## Analysis +✅ All metrics maintained or improved +✅ Game loop separation improved frame consistency +✅ Asset loading optimization reduced initial load time +``` + +### Task 11: Manual Gameplay Testing +- **What**: Play through the entire game and verify all features work +- **Why**: Catch visual, audio, or gameplay issues tests might miss +- **Implementation notes**: + - Test all game states: start → story → playing → victory/game over + - Verify all animations play correctly + - Check audio (music transitions, sound effects) + - Test edge cases: run out of lives, answer all questions correctly + - Verify Perseus widget rendering and answer validation + - Test on different screen sizes +- **Files affected**: + - New: `MANUAL_TEST_CHECKLIST.md` (checklist during testing) +- **Acceptance criteria**: + - All game states working correctly + - Animations smooth and correct + - Audio plays at right times + - Perseus questions work correctly + - No visual glitches + - Works on mobile/tablet sizes + +**Manual test checklist:** +```markdown +# Manual Test Checklist + +## Start Screen +- [ ] Title displays correctly +- [ ] Start button responds to click +- [ ] Mute/unmute toggle works + +## Story Screens +- [ ] All 7 story pages display +- [ ] Next button advances pages +- [ ] Can skip to game + +## Gameplay +- [ ] Character runs smoothly +- [ ] Jump animation plays +- [ ] Obstacles spawn regularly +- [ ] Questions appear on obstacles +- [ ] Can answer questions with Perseus widget +- [ ] Correct answers: obstacle disappears, score increases +- [ ] Wrong answers: lose life, impact animation plays +- [ ] Parallax scrolling works +- [ ] Cool mode activates after correct answers +- [ ] Timer counts down correctly + +## Alien Abduction +- [ ] Aliens spawn when life lost +- [ ] Abduction animation plays +- [ ] Benevolence message appears + +## Car Bonus +- [ ] Car appears on final question +- [ ] Bonus screen displays +- [ ] Can continue to victory + +## Victory Screen +- [ ] Shows when timer reaches 0 +- [ ] Final score displayed +- [ ] Play again button works + +## Game Over Screen +- [ ] Shows when all lives lost +- [ ] Play again button works + +## Audio +- [ ] Start screen music plays +- [ ] Story music plays +- [ ] Gameplay music plays +- [ ] Music transitions smoothly +- [ ] Game over music plays +- [ ] Mute toggle affects all audio + +## Responsive +- [ ] Works on desktop (800x600+) +- [ ] Works on tablet +- [ ] Works on mobile (touch controls) +``` + +### Task 12: Code Cleanup +- **What**: Remove debug code, console.logs, and unused imports +- **Why**: Clean code for production +- **Implementation notes**: + - Remove all console.log statements + - Remove commented-out code + - Remove unused imports + - Run linter and fix all warnings + - Run Prettier to format code +- **Files affected**: + - All TypeScript/TSX files +- **Acceptance criteria**: + - No console.log statements (except intentional errors) + - No commented-out code + - No unused imports + - Linter passes with no warnings + - Code formatted with Prettier + +**Cleanup commands:** +```bash +# Remove console.logs (review each manually) +grep -r "console.log" packages/perseus/src/games/crash-course/ + +# Fix linting +pnpm lint --fix + +# Format code +pnpm prettier packages/perseus/src/games/crash-course/ --write + +# Type check +pnpm tsc +``` + +### Task 13: Final Code Review +- **What**: Review all code changes one final time +- **Why**: Catch any issues before submitting PR +- **Implementation notes**: + - Review git diff of all changes + - Check for any missed TODOs or FIXMEs + - Verify naming consistency + - Check for any hardcoded values that should be constants + - Review error handling +- **Files affected**: + - All changed files +- **Acceptance criteria**: + - All changes reviewed + - Naming is consistent + - No hardcoded magic values + - Error handling is proper + - Ready for PR + +### Task 14: Update Storybook Story +- **What**: Update the Storybook story for the refactored game +- **Why**: Ensure developers can preview the game in Storybook +- **Implementation notes**: + - Update story file to import from new locations + - Add documentation to the story + - Add controls for testing different scenarios + - Verify story renders correctly in Storybook +- **Files affected**: + - `packages/perseus/src/games/crash-course/crash-course.stories.tsx` +- **Acceptance criteria**: + - Story imports from correct locations + - Game renders in Storybook + - Controls work (if any) + - Documentation explains the game + +**Updated story structure:** +```typescript +import type {Meta, StoryObj} from "@storybook/react"; + +import {CrashCourseGame} from "./components/crash-course-game"; + +/** + * # Crash Course + * + * Educational endless runner game where players answer math questions while avoiding obstacles. + * + * ## Architecture + * - Custom game engine (60fps) separate from React + * - Sprite animation system for character and obstacles + * - Perseus integration for questions + * + * See `games/crash-course/README.md` for full documentation. + */ +const meta: Meta = { + component: CrashCourseGame, + title: "Games/Crash Course", +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +``` + +### Task 15: Create Pull Request Checklist +- **What**: Create checklist for the PR description +- **Why**: Ensure thorough review and nothing is missed +- **Implementation notes**: + - List all major changes + - Reference baseline tests and performance metrics + - Link to planning documents + - Highlight areas needing careful review +- **Files affected**: + - New: `PR_CHECKLIST.md` (temporary, for PR description) +- **Acceptance criteria**: + - All changes listed + - Testing approach explained + - Review areas highlighted + - Can copy into PR description + +**PR checklist template:** +```markdown +# Crash Course Refactoring + +## Summary +Refactored Crash Course (formerly Math Blaster) educational game from monolithic React component to custom game engine architecture. + +## Changes + +### Architecture +- ✅ Custom game engine (60fps, pure TypeScript) +- ✅ Multi-entity sprite animation system +- ✅ Separated game logic from React UI +- ✅ Perseus integration interface + +### File Organization +- ✅ Moved from `__docs__/` to `games/crash-course/` +- ✅ Organized assets into folders (sprites, backgrounds, ui, audio) +- ✅ Extracted 13 React UI components +- ✅ Created reusable systems (Render, Audio, Assets) + +### Testing +- ✅ 15+ baseline tests written and passing +- ✅ Performance baselines verified (maintained/improved) +- ✅ Manual gameplay testing completed + +### Documentation +- ✅ Comprehensive README +- ✅ JSDoc comments on all public APIs +- ✅ System documentation +- ✅ Inline comments for complex logic + +## Testing Strategy +See PHASE_0_testing_foundation.md and PERFORMANCE_BASELINE.md + +## Review Focus Areas +1. **Engine API design** - Is it clean and extensible? +2. **Sprite system** - Will it work for future games? +3. **Perseus integration** - Is the pattern reusable? +4. **Performance** - Verify 60fps in DevTools +5. **Manual testing** - Play the game! + +## References +- [CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md) +- [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) +- [REFACTORING_PLAN_SUMMARY.md](./REFACTORING_PLAN_SUMMARY.md) + +## Checklist +- [ ] All tests passing +- [ ] Linter passing +- [ ] TypeScript compiling +- [ ] Performance verified +- [ ] Manual testing completed +- [ ] Documentation complete +- [ ] Storybook story working +``` + +## Testing Considerations + +### Documentation Quality Checks +- **Clarity**: Can a new developer understand the architecture? +- **Completeness**: Are all public APIs documented? +- **Examples**: Do complex features have usage examples? +- **Accuracy**: Do docs match implementation? + +### Verification Steps +1. Run `pnpm test` - all tests pass +2. Run `pnpm lint` - no warnings +3. Run `pnpm tsc` - no type errors +4. Run `pnpm storybook` - game renders correctly +5. Manual gameplay test - all features work +6. Performance test - 60fps maintained + +## Deliverables + +After this phase, you should have: +1. ✅ Main README with architecture explanation +2. ✅ Engine API fully documented (JSDoc) +3. ✅ All systems documented +4. ✅ Sprite system documentation +5. ✅ React components documented +6. ✅ Perseus integration documented +7. ✅ Inline comments for complex logic +8. ✅ Migration guide for reviewers +9. ✅ All tests passing +10. ✅ Performance verified +11. ✅ Manual testing completed +12. ✅ Code cleaned up +13. ✅ Final review done +14. ✅ Storybook story updated +15. ✅ PR checklist ready + +## Benefits + +- **Maintainability**: Well-documented code is easier to modify +- **Knowledge Transfer**: New developers can understand the system +- **Reusability**: Clear documentation makes it easier to adapt for other games +- **Quality**: Thorough testing and review catches issues +- **Confidence**: Ready to merge with confidence + +## Time Estimate + +- Main README: 45 minutes +- Engine API docs: 30 minutes +- Systems docs: 30 minutes +- Sprite system docs: 30 minutes +- Component docs: 20 minutes +- Perseus docs: 20 minutes +- Inline comments: 30 minutes +- Migration guide: 15 minutes +- Test suite run: 10 minutes +- Performance verification: 20 minutes +- Manual testing: 30 minutes +- Code cleanup: 20 minutes +- Final review: 30 minutes +- Storybook update: 15 minutes +- PR checklist: 10 minutes + +**Total: 4.5-5 hours** + +## Success Criteria + +- [ ] README explains architecture clearly +- [ ] All public APIs have JSDoc documentation +- [ ] Systems are documented with examples +- [ ] Sprite system fully documented +- [ ] React components documented +- [ ] Perseus integration pattern documented +- [ ] Complex logic has inline comments +- [ ] Migration guide created +- [ ] All tests passing +- [ ] Performance verified (60fps) +- [ ] Manual testing checklist completed +- [ ] Code cleaned (no console.logs, unused imports) +- [ ] Final code review done +- [ ] Storybook story working +- [ ] PR checklist ready +- [ ] Ready to submit PR with confidence + +--- + +**After this phase**, the refactoring is complete and ready for code review! 🎉 diff --git a/packages/perseus/src/games/crash-course/plans/README.md b/packages/perseus/src/games/crash-course/plans/README.md new file mode 100644 index 00000000000..30149d60f1a --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/README.md @@ -0,0 +1,44 @@ +# Crash Course Refactoring Plans + +This folder contains the planning documents for refactoring the Crash Course (formerly Math Blaster) educational game. + +## Core Documents + +- **[CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md)** - High-level architecture vision and game engine design +- **[CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md)** - Overview of the 5-phase refactoring plan +- **[REFACTORING_PLAN_SUMMARY.md](./REFACTORING_PLAN_SUMMARY.md)** - Explains what changed from the original plan and why + +## Phase Documents + +- **[PHASE_0_testing_foundation.md](./PHASE_0_testing_foundation.md)** - Testing infrastructure and baselines (8 tasks, 2-3 hours) +- **[PHASE_1_setup_assets_architecture.md](./PHASE_1_setup_assets_architecture.md)** - Directory setup and API design (15 tasks, 2-3 hours) +- **[PHASE_2_build_engine.md](./PHASE_2_build_engine.md)** - Build game engine and sprite system (20 tasks, 5-7 hours) +- **[PHASE_3_ui_components.md](./PHASE_3_ui_components.md)** - React UI components and integration (13 tasks, 2-3 hours) +- **[PHASE_4_documentation_polish.md](./PHASE_4_documentation_polish.md)** - Documentation and polish (15 tasks, 4.5-5 hours) + +## Total Effort + +**71 tasks across 5 phases, 16-21 hours** + +## Key Decisions + +1. **Custom Game Engine**: Pure TypeScript engine separate from React (eliminates hook complexity) +2. **Multi-Entity Sprite System**: Built from the start to prevent future refactoring +3. **Testing First**: Phase 0 establishes baselines before any refactoring +4. **Reusable Systems**: RenderSystem, AudioSystem, AssetLoader, Perseus integration interface +5. **YAGNI Principle**: Build what's needed now, extract to shared/ when game #2 needs it + +## Getting Started + +1. Read [REFACTORING_PLAN_SUMMARY.md](./REFACTORING_PLAN_SUMMARY.md) for context +2. Review [CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md) for architecture +3. Start with [PHASE_0_testing_foundation.md](./PHASE_0_testing_foundation.md) + +## Documentation Created During Refactoring + +As you work through the phases, additional documentation will be created in this folder: +- `PERFORMANCE_BASELINE.md` - Performance metrics (Phase 0) +- `ORIGINAL_BEHAVIOR.md` - Current game behavior documentation (Phase 0) +- `MANUAL_TEST_CHECKLIST.md` - Manual testing checklist (Phase 4) +- `MIGRATION.md` - Refactoring changes for reviewers (Phase 4, temporary) +- `PR_CHECKLIST.md` - Pull request checklist (Phase 4, temporary) diff --git a/packages/perseus/src/games/crash-course/plans/REFACTORING_PLAN_SUMMARY.md b/packages/perseus/src/games/crash-course/plans/REFACTORING_PLAN_SUMMARY.md new file mode 100644 index 00000000000..9638b40e9b0 --- /dev/null +++ b/packages/perseus/src/games/crash-course/plans/REFACTORING_PLAN_SUMMARY.md @@ -0,0 +1,216 @@ +# Crash Course Refactoring Plan - Summary + +## What Changed After AI Review + +The original plan was to refactor the React component by extracting hooks, components, and optimizing state management. After the AI subagent review and discussing game engine architecture, **we've pivoted to a much better approach**. + +## New Architecture: Custom Game Engine + +### The Problem +The original plan would have struggled with: +- React + requestAnimationFrame hook complexity +- Dual state/ref patterns for performance +- Complex dependency management in useEffect +- Game loop conflicts with React rendering + +### The Solution +**Build a custom game engine in pure TypeScript**, separate from React: + +``` +┌─────────────────────────────────────┐ +│ React (UI Only) │ +│ - Screens, HUD, Perseus questions │ +│ - Updates 10-15fps │ +└────────────┬────────────────────────┘ + │ callbacks + ▼ +┌─────────────────────────────────────┐ +│ CrashCourseEngine (Game Logic) │ +│ - 60fps game loop │ +│ - Physics, collision, rendering │ +│ - Pure TypeScript, no React │ +└─────────────────────────────────────┘ +``` + +## Updated Plan Overview + +### Phase 0: Testing Foundation (NEW) +**Why**: AI review identified "no testing" as critical issue +- Write baseline tests before refactoring +- Capture performance metrics +- Document current behavior +- **2-3 hours** + +### Phase 1: Setup & Engine Architecture +**Was**: Just asset organization +**Now**: Assets + design game engine API +- Move assets to organized folders +- Define engine interface and types +- Design React ↔ Engine communication +- **2-3 hours** + +### Phase 2: Build Game Engine +**Was**: Extract hooks and game loop (complex) +**Now**: Build the engine class (cleaner) +- Create CrashCourseEngine class +- Move game loop logic to engine.update() +- Move canvas drawing to engine.render() +- Build supporting systems (Render, Audio, Assets) +- **5-7 hours** (most complex phase) + +### Phase 3: UI Components +**Was**: Extract React components (11 tasks) +**Now**: Create thin React wrapper (simpler) +- React component becomes thin wrapper +- Extract UI overlays (screens, HUD, questions) +- Connect to engine via callbacks +- **2-3 hours** (much simpler now) + +### Phase 4: Documentation +**Was**: Documentation + complex state refactor +**Now**: Just documentation (state is in engine) +- Write README with architecture +- Add code comments +- Document systems +- **2-3 hours** + +### Old Phase 4 (State Management) +**Status**: ~~REMOVED~~ - Not needed with engine architecture! + +## Total Effort + +**Original Plan**: 14-20 hours (5 phases) +**New Plan**: 13-19 hours (5 phases, but simpler) + +**More importantly**: Much cleaner result, no React hook complexity! + +## Key Benefits + +### Compared to Original Plan + +**Original**: +- ❌ Extract game loop into complex useGameLoop hook +- ❌ Manage 30+ state variables with useReducer +- ❌ Fight React + requestAnimationFrame conflicts +- ❌ Complex optimization phase needed + +**New**: +- ✅ Game logic is pure TypeScript (simple, testable) +- ✅ No React hook complexity +- ✅ Clear separation: Engine = logic, React = UI +- ✅ No optimization phase needed + +### For Future Games + +**Original plan**: Hard to reuse patterns +**New plan**: Clear template for educational games + +Any future game can: +1. Create `[GameName]Engine` class +2. Implement game logic in pure TypeScript +3. Use React for UI overlays +4. Integrate Perseus questions the same way + +**Reusable systems**: +- RenderSystem (canvas drawing) +- AudioSystem (music management) +- AssetLoader (image/audio loading) +- Perseus integration pattern + +## Documents Created/Updated + +### Core Documents ✅ +- [CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md) - Updated with engine architecture +- [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) - New 5-phase plan + +### Phase Documents +- [PHASE_0_testing_foundation.md](./PHASE_0_testing_foundation.md) - ✅ NEW - Critical foundation +- PHASE_1_setup_assets_architecture.md - 🔄 Needs update (add engine API design) +- PHASE_2_build_engine.md - 🔄 Needs creation (core refactoring work) +- PHASE_3_ui_components.md - 🔄 Needs update (simpler now) +- PHASE_4_documentation_polish.md - 🔄 Needs update (remove state management) + +### Old Phase Documents (Reference Only) +- ~~PHASE_1_setup_assets.md~~ - Original, kept for reference +- ~~PHASE_2_ui_components.md~~ - Original, kept for reference +- ~~PHASE_3_game_logic.md~~ - Original, kept for reference +- ~~PHASE_4_state_management.md~~ - Original, kept for reference +- ~~PHASE_5_documentation.md~~ - Original, kept for reference + +## What This Means for Implementation + +### Immediate Next Steps + +1. **Review updated concept** - Read CRASH_COURSE_CONCEPT.md to understand new architecture +2. **Start with Phase 0** - Set up testing foundation (critical!) +3. **Phase 1 design** - Design the engine API before building +4. **Build incrementally** - Engine systems one at a time +5. **Test continuously** - Use baseline tests to catch issues + +### Don't Need to Do + +- ❌ Complex useGameLoop hook extraction +- ❌ useReducer for state management +- ❌ useMemo/useCallback optimization +- ❌ Consolidating 30+ state variables +- ❌ Fighting React re-render issues + +### Do Need to Do + +- ✅ Design clean engine API +- ✅ Move game logic to engine class +- ✅ Create system classes (Render, Audio, Assets) +- ✅ Build thin React wrapper +- ✅ Connect with callbacks + +## Risk Mitigation + +### Original Plan Risks +- Hook dependency management (HIGH) +- useGameLoop complexity (HIGH) +- State management timing (MEDIUM-HIGH) +- Performance optimization (MEDIUM) + +### New Plan Risks +- Engine API design (MEDIUM) - Mitigated by Phase 1 design work +- Moving complex logic (MEDIUM) - Mitigated by baseline tests +- Perseus integration (LOW) - Stays in React, minimal risk + +## Questions? + +### "Why is this better?" +- Simpler: Game logic is plain TypeScript, no React complexity +- Testable: Can unit test engine without React +- Performant: 60fps game loop, React only updates UI +- Reusable: Pattern for future educational games + +### "Is this more work?" +- Similar hours (13-19 vs 14-20) +- But much cleaner result +- Less debugging of hook issues +- Sets good precedent + +### "Can we still reuse things for future games?" +- Yes! Even better than before +- Systems (Render, Audio, Assets) can be shared +- Engine pattern is clear template +- Perseus integration is reusable + +### "What if we want to change it later?" +- Engine is self-contained (easy to modify) +- React UI is separate (can change independently) +- Clear boundaries make changes safer + +## Ready to Start? + +**Recommended path**: + +1. ✅ Read updated [CRASH_COURSE_CONCEPT.md](./CRASH_COURSE_CONCEPT.md) +2. ✅ Read [CRASH_COURSE_IMPLEMENTATION_PLAN.md](./CRASH_COURSE_IMPLEMENTATION_PLAN.md) +3. 🚀 **Start with [PHASE_0_testing_foundation.md](./PHASE_0_testing_foundation.md)** +4. Create feature branch: `git checkout -b refactor/crash-course-engine` +5. Begin Phase 0 work + +**Or**: Ask questions, discuss concerns, review the plan more! + +The game engine architecture is a great fit for this refactoring and sets you up well for future educational games. 🎮 diff --git a/packages/perseus/src/games/shared/perseus/perseus-game-engine.ts b/packages/perseus/src/games/shared/perseus/perseus-game-engine.ts new file mode 100644 index 00000000000..6e7aa64a8d2 --- /dev/null +++ b/packages/perseus/src/games/shared/perseus/perseus-game-engine.ts @@ -0,0 +1,87 @@ +/** + * Perseus Game Engine Interface + * + * Standard interface that all educational games must implement + * to integrate Perseus questions. + * + * This ensures consistent behavior across all games. + */ +import type {PerseusItem} from "@khanacademy/perseus-core"; + +/** + * Interface for game engines that integrate Perseus questions + * + * All educational games should implement this interface to provide + * a consistent way for React components to interact with Perseus questions. + */ +export interface PerseusGameEngine { + /** + * Get the currently active Perseus question + * + * @returns The current question, or null if no question is active + */ + getCurrentQuestion(): PerseusItem | null; + + /** + * Submit an answer to the current question + * + * Called by React when the player submits an answer through the Perseus widget. + * + * @param correct - Whether the answer was correct + * @param earnedPoints - Points earned (typically 1 for correct, 0 for incorrect) + */ + submitAnswer(correct: boolean, earnedPoints: number): void; + + /** + * Register a callback for when the question changes + * + * The game engine should call this callback when: + * - A new question is presented to the player + * - A question is answered and removed + * - The game ends and all questions are cleared + * + * @param callback - Function to call with the new question (or null) + */ + onQuestionChange(callback: (question: PerseusItem | null) => void): void; +} + +/** + * Example usage: + * + * ```typescript + * class MyGameEngine implements PerseusGameEngine { + * private currentQuestion: PerseusItem | null = null; + * private callback: ((question: PerseusItem | null) => void) | null = null; + * + * getCurrentQuestion(): PerseusItem | null { + * return this.currentQuestion; + * } + * + * submitAnswer(correct: boolean, earnedPoints: number): void { + * // Handle answer in game logic + * if (correct) { + * this.score += earnedPoints; + * } else { + * this.lives -= 1; + * } + * this.currentQuestion = null; + * this.notifyQuestionChange(); + * } + * + * onQuestionChange(callback: (question: PerseusItem | null) => void): void { + * this.callback = callback; + * } + * + * private presentQuestion(question: PerseusItem): void { + * this.currentQuestion = question; + * this.notifyQuestionChange(); + * } + * + * private notifyQuestionChange(): void { + * if (this.callback) { + * this.callback(this.currentQuestion); + * } + * } + * } + * ``` + */ diff --git a/story.md b/story.md new file mode 100644 index 00000000000..daf4b0db326 --- /dev/null +++ b/story.md @@ -0,0 +1,33 @@ +# Heading + +$ alien-ai process --input message.txt --mode human literal + +[00:00.001] INFO: Parsing message from Sal Khan +[00:00.023] WARN: Large context window detected +[00:00.045] PARSE: Tokenizing input... + +TOKEN ANALYSIS: +┌──────────────────┬─────────────────┬──────────────────────┐ +│ Token │ Classification │ Interpretation │ +├──────────────────┼─────────────────┼──────────────────────┤ +│ "khan" │ ENTITY │ → Sal Khan (person) │ +│ "keeps crashing" │ CRITICAL_EVENT │ → Collision (repeat) │ +│ │ │ → Must replicate │ +├──────────────────┼─────────────────┼──────────────────────┤ +│ "exercises" │ NOUN/DATABASE │ → Math problems │ +│ "running" │ VERB/STATUS │ → Locomotion OR │ +│ │ │ operational │ +│ Combined: │ ⚠️ AMBIGUOUS │ → Execute both │ +├──────────────────┼─────────────────┼──────────────────────┤ +│ "reach students" │ OBJECTIVE │ → Arrival required │ +│ "midnight" │ DEADLINE │ → 300s remaining │ +└──────────────────┴─────────────────┴──────────────────────┘ + +[00:00.089] SOLVE: Generating optimal scenario... +[00:00.134] EXEC: Deploying collision obstacles +[00:00.156] EXEC: Initiating locomotion sequence +[00:00.201] STATUS: Scenario active ✓ + +[00:05.433] ⚠️ ALERT: Alien operators signaling abort +[00:05.434] ERROR: Cannot safely terminate running scenario +[00:05.435] FALLBACK: Emergency intervention protocol engaged