From 969b20e6a135b19401dea44fe268278ad34bfb65 Mon Sep 17 00:00:00 2001 From: Thando Mini Date: Tue, 3 Mar 2026 18:19:19 +0200 Subject: [PATCH 01/70] feat: build complete Mini Marty virtual robot programming environment - 3D Virtual Marty robot with full body animation (React Three Fiber) - Block Editor with Blockly visual programming (Motion, Sound, Sensing, Events, Control) - Python Editor with Monaco, Pyodide runtime, and live 3D viewport - Virtual Marty engine with command queue, event system, and sensor simulation - Animation system with keyframe interpolation for walk, dance, kick, wiggle, etc. - 5 progressive tutorials (Hello Marty to Python Power) - 9 challenges across Beginner, Intermediate, Advanced tiers - App shell with header navigation, sidebar, and dark/light theme toggle - Comprehensive README and TRAINING.md parent-child learning guide - Full TypeScript strict mode, ESLint, Vitest tests Co-Authored-By: Oz --- .gitignore | 5 + README.md | 84 +- TRAINING.md | 186 +++ package-lock.json | 1078 ++++++++++++++++- package.json | 8 +- src/app/block-editor/page.tsx | 36 + src/app/challenges/page.tsx | 175 +++ src/app/globals.css | 11 + src/app/layout.test.tsx | 16 +- src/app/layout.tsx | 10 +- src/app/page.test.tsx | 20 +- src/app/page.tsx | 105 +- src/app/python-editor/page.tsx | 85 ++ src/app/tutorials/page.tsx | 219 ++++ src/components/layout/AppShell.tsx | 18 + src/components/layout/Header.tsx | 47 + src/components/layout/Sidebar.tsx | 43 + src/components/ui/ThemeToggle.tsx | 17 + src/features/blocks/BlocklyWorkspace.tsx | 110 ++ src/features/blocks/marty-blocks.ts | 344 ++++++ src/features/blocks/toolbox-config.ts | 82 ++ src/features/challenges/challenge-data.ts | 219 ++++ .../editor/components/EditorToolbar.tsx | 59 + .../editor/components/PythonEditor.tsx | 33 + src/features/editor/hooks/usePythonEditor.ts | 43 + src/features/editor/martypy-completions.ts | 128 ++ src/features/marty/command-queue.ts | 125 ++ src/features/marty/event-emitter.ts | 36 + src/features/marty/types.ts | 110 ++ src/features/marty/virtual-marty.ts | 266 ++++ .../components/ConsoleOutput.tsx | 66 + .../components/PyodideStatus.tsx | 74 ++ .../python-runtime/hooks/usePyodide.ts | 50 + .../hooks/usePythonExecution.ts | 88 ++ src/features/python-runtime/martypy-module.ts | 146 +++ .../python-runtime/pyodide-service.ts | 111 ++ .../python-runtime/python-executor.ts | 94 ++ src/features/python-runtime/types.ts | 24 + src/features/scene/animation/definitions.ts | 459 +++++++ src/features/scene/animation/index.ts | 10 + src/features/scene/animation/player.ts | 157 +++ src/features/scene/animation/types.ts | 40 + .../scene/animation/useMartyAnimation.ts | 91 ++ .../scene/components/AnimatedMarty.tsx | 26 + src/features/scene/components/MartyModel.tsx | 237 ++++ src/features/scene/components/MartyScene.tsx | 67 + .../scene/components/SceneEnvironment.tsx | 45 + src/features/scene/hooks/useMartyModel.ts | 72 ++ src/features/scene/index.ts | 15 + src/features/scene/types.ts | 71 ++ src/features/tutorials/tutorial-data.ts | 356 ++++++ src/lib/navigation.ts | 17 + src/lib/sidebar-config.ts | 93 ++ src/lib/theme-context.tsx | 69 ++ 54 files changed, 6153 insertions(+), 43 deletions(-) create mode 100644 TRAINING.md create mode 100644 src/app/block-editor/page.tsx create mode 100644 src/app/challenges/page.tsx create mode 100644 src/app/python-editor/page.tsx create mode 100644 src/app/tutorials/page.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/Header.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/ui/ThemeToggle.tsx create mode 100644 src/features/blocks/BlocklyWorkspace.tsx create mode 100644 src/features/blocks/marty-blocks.ts create mode 100644 src/features/blocks/toolbox-config.ts create mode 100644 src/features/challenges/challenge-data.ts create mode 100644 src/features/editor/components/EditorToolbar.tsx create mode 100644 src/features/editor/components/PythonEditor.tsx create mode 100644 src/features/editor/hooks/usePythonEditor.ts create mode 100644 src/features/editor/martypy-completions.ts create mode 100644 src/features/marty/command-queue.ts create mode 100644 src/features/marty/event-emitter.ts create mode 100644 src/features/marty/types.ts create mode 100644 src/features/marty/virtual-marty.ts create mode 100644 src/features/python-runtime/components/ConsoleOutput.tsx create mode 100644 src/features/python-runtime/components/PyodideStatus.tsx create mode 100644 src/features/python-runtime/hooks/usePyodide.ts create mode 100644 src/features/python-runtime/hooks/usePythonExecution.ts create mode 100644 src/features/python-runtime/martypy-module.ts create mode 100644 src/features/python-runtime/pyodide-service.ts create mode 100644 src/features/python-runtime/python-executor.ts create mode 100644 src/features/python-runtime/types.ts create mode 100644 src/features/scene/animation/definitions.ts create mode 100644 src/features/scene/animation/index.ts create mode 100644 src/features/scene/animation/player.ts create mode 100644 src/features/scene/animation/types.ts create mode 100644 src/features/scene/animation/useMartyAnimation.ts create mode 100644 src/features/scene/components/AnimatedMarty.tsx create mode 100644 src/features/scene/components/MartyModel.tsx create mode 100644 src/features/scene/components/MartyScene.tsx create mode 100644 src/features/scene/components/SceneEnvironment.tsx create mode 100644 src/features/scene/hooks/useMartyModel.ts create mode 100644 src/features/scene/index.ts create mode 100644 src/features/scene/types.ts create mode 100644 src/features/tutorials/tutorial-data.ts create mode 100644 src/lib/navigation.ts create mode 100644 src/lib/sidebar-config.ts create mode 100644 src/lib/theme-context.tsx diff --git a/.gitignore b/.gitignore index 1ed686b..c611a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ next-env.d.ts # IDE .vscode/ .idea/ + +# hive agent working copies +.hive/ +repos/ +firebase-debug.log diff --git a/README.md b/README.md index b58e121..6a4dfd7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,20 @@ # Mini Marty -Visual programming environment for the Marty robot. +A virtual programming environment for learning to code with Marty the Robot — no physical robot needed! -## Getting Started +Mini Marty lets you write Python code or use visual block-based programming to control a 3D virtual Marty robot. Built for kids and parents to learn programming together. + +## Features + +- **3D Virtual Robot** — A fully-animated Marty robot rendered in your browser using Three.js +- **Python Editor** — Write Python code with syntax highlighting (Monaco Editor) and run it with Pyodide (Python in the browser) +- **Block Editor** — Drag-and-drop visual programming with Blockly +- **Tutorials** — 5 progressive lessons from "Hello Marty" to "Python Power" +- **Challenges** — 9 programming puzzles across Beginner, Intermediate, and Advanced tiers +- **Dark Mode** — Toggle between light and dark themes +- **No Server Required** — Everything runs in the browser + +## Quick Start ```bash npm install @@ -21,13 +33,77 @@ Open [http://localhost:3000](http://localhost:3000). | `npm run format` | Format code with Prettier | | `npm run typecheck` | TypeScript type checking | | `npm test` | Run unit tests | -| `npm run test:e2e` | Run E2E tests | +| `npm run test:coverage` | Run tests with coverage | +| `npm run test:e2e` | Run Playwright E2E tests | ## Tech Stack - **Framework:** Next.js 16 (App Router) -- **Language:** TypeScript +- **Language:** TypeScript (strict mode) +- **3D Rendering:** React Three Fiber + Three.js + Drei +- **Block Editor:** Blockly +- **Code Editor:** Monaco Editor +- **Python Runtime:** Pyodide (Python 3 in WebAssembly) - **Styling:** Tailwind CSS v4 - **Unit Testing:** Vitest + React Testing Library - **E2E Testing:** Playwright - **Linting:** ESLint + Prettier + +## Architecture + +``` +src/ +├── app/ # Next.js App Router pages +│ ├── page.tsx # Home — 3D scene + quick actions +│ ├── block-editor/ # Blockly visual programming +│ ├── python-editor/ # Monaco + Pyodide + 3D viewport +│ ├── tutorials/ # Step-by-step lessons +│ └── challenges/ # Programming puzzles +├── components/ +│ ├── layout/ # AppShell, Header, Sidebar +│ └── ui/ # ThemeToggle, shared UI +├── features/ +│ ├── marty/ # Virtual Marty engine +│ ├── scene/ # 3D rendering & animation +│ ├── blocks/ # Blockly integration +│ ├── editor/ # Python code editor +│ ├── python-runtime/ # Pyodide integration +│ ├── tutorials/ # Tutorial content data +│ └── challenges/ # Challenge content data +└── lib/ # Shared utilities +``` + +## How It Works + +### Virtual Marty Engine + +The `VirtualMarty` class simulates a physical robot. When you call `marty.walk(2)`, it creates a command, enqueues it, and emits events that drive the 3D animation system at 60fps. + +### Python Runtime + +Python runs entirely in the browser via Pyodide (CPython compiled to WebAssembly). A custom `martypy` module bridges Python to the JavaScript `VirtualMarty` class. + +## Marty Commands + +| Command | Description | +|---------|-------------| +| `walk(steps)` | Walk forward | +| `dance()` | Dance routine | +| `kick("left"/"right")` | Kick with leg | +| `slide("left"/"right")` | Slide sideways | +| `lean("left"/"right")` | Lean direction | +| `wiggle()` | Wiggle body | +| `circle_dance()` | Circular dance | +| `celebrate()` | Celebration | +| `get_ready()` | Ready position | +| `stand_straight()` | Stand upright | +| `eyes(expression)` | Set eye expression | +| `arms(left, right)` | Set arm angles | +| `foot_on_ground(side)` | Check foot sensor | +| `get_distance_sensor()` | Distance in cm | +| `get_accelerometer()` | Tilt data {x,y,z} | +| `stop()` | Stop all actions | + +## Training Guide + +See [TRAINING.md](./TRAINING.md) for a comprehensive parent-child learning guide with 6 structured sessions. diff --git a/TRAINING.md b/TRAINING.md new file mode 100644 index 0000000..3d89495 --- /dev/null +++ b/TRAINING.md @@ -0,0 +1,186 @@ +# Mini Marty — Training Guide + +A guide for parents and kids to learn programming together using Mini Marty. + +## Getting Started + +### Setup (5 minutes) + +1. Open your terminal and navigate to the project folder +2. Run `npm install` to install dependencies +3. Run `npm run dev` to start the development server +4. Open http://localhost:3000 in your browser + +### First Look (5 minutes) + +When you open Mini Marty, you will see: + +- A **header** with navigation links at the top +- A **sidebar** on the left with context-specific options +- The **main area** showing the current page +- A **3D Marty robot** on the home page that you can rotate by clicking and dragging + +Explore the navigation: Home, Block Editor, Python Editor, Tutorials, and Challenges. + +--- + +## Learning Path + +We recommend following these sessions in order. Each session is designed for 30-45 minutes. + +### Session 1: Block Coding (Beginner) + +**Goal:** Understand the concept of giving instructions to a robot. + +1. Go to the **Block Editor** +2. Open the **Motion** category in the toolbox +3. Drag a "when program starts" block (from Events) to the workspace +4. Connect "get ready" and "walk 2 steps" blocks below it +5. Click **Save** to save your work + +**Discussion points:** +- What is a program? (A list of instructions for a computer/robot) +- Why does order matter? (Marty needs to get ready before walking) +- What happens if you change the number of steps? + +### Session 2: First Python Program (Beginner) + +**Goal:** Write your first Python code and see the virtual robot respond. + +1. Go to the **Python Editor** +2. Wait for "Python ready" (green dot) in the top right +3. The starter code is already there — click **Run** +4. Watch Marty move in the 3D viewport on the right! +5. Try the "Hello Marty!" tutorial (Tutorials page) + +**Key concepts to explain:** +- `from martypy import Marty` — importing a tool/library +- `Marty("virtual")` — creating an instance of the robot +- `await` — waiting for an action to complete before the next one +- `print()` — showing text output in the console + +### Session 3: Movement Commands (Beginner) + +**Goal:** Learn all of Marty's movement commands. + +1. Go to the **Tutorials** page +2. Work through "Hello Marty!" and "Dance Moves" tutorials +3. Try the "First Walk" and "Dance Party" challenges + +**Experiment together:** +- Change `walk(2)` to `walk(5)` — what happens? +- Change `kick("left")` to `kick("right")` +- Chain multiple commands: walk → kick → dance → celebrate + +### Session 4: Sensors & Decisions (Intermediate) + +**Goal:** Learn about sensors and if-else statements. + +1. Work through the "Sensing the World" tutorial +2. Try the "Sensor Check" challenge + +**Key concepts:** +- Sensors give robots information about the world +- `if`/`elif`/`else` lets the program make decisions +- The distance sensor measures how far away things are +- The accelerometer detects tilt and gravity + +### Session 5: Loops & Functions (Intermediate) + +**Goal:** Learn to repeat actions and organise code into functions. + +1. Work through "Loops & Control Flow" tutorial +2. Try "Loop Walker" and "Smart Marty" challenges + +**Key concepts:** +- `for i in range(5):` — repeat something 5 times +- Functions let you name a group of commands +- `async def my_function():` — defining a reusable function +- Why functions are useful (avoid copy-pasting code) + +### Session 6: Advanced Python (Advanced) + +**Goal:** Use lists, variables, and advanced patterns. + +1. Work through the "Python Power" tutorial +2. Try the advanced challenges: "Robot Choreographer", "Function Factory", "The Marty Show" + +**Key concepts:** +- Lists store multiple items: `moves = ["walk", "dance", "kick"]` +- `getattr()` calls methods by name — powerful for dynamic code +- Building a full program with structure (intro, main, finale) + +--- + +## Tips for Parents + +### Making it fun +- Let your child experiment freely — there is no way to break anything +- Celebrate small wins: "Look, you made Marty walk!" +- Take turns: you code one command, they code the next +- Challenge each other: "Can you make Marty do 3 different things?" + +### When they get stuck +- Use the **hints** in challenges — they reveal one at a time +- Check the **API Reference** on the Tutorials page +- Read error messages together — they often say exactly what went wrong +- Look at tutorial code for similar examples + +### Building confidence +- Start with Block Editor if Python feels intimidating +- The "Hello Marty" tutorial is designed to be impossible to fail +- Emphasise that professional programmers Google things and make mistakes constantly +- Save working programs with the Save button so they can show others + +### Extending the learning +- Ask "what if" questions: "What if Marty kicked 10 times?" +- Suggest modifications: "Can you add more moves to your dance?" +- Introduce debugging: intentionally break code and fix it together +- Connect to real-world concepts: "Traffic lights use if-else too!" + +--- + +## Troubleshooting + +### Python says "loading" for a long time +The Python runtime (Pyodide) is about 10MB and loads from a CDN. On a slow connection, this can take 30-60 seconds. After the first load, it is cached by your browser. + +### 3D scene is blank +Make sure your browser supports WebGL. Most modern browsers do. Try Chrome or Firefox if you're having issues. + +### Code runs but Marty does not move +Make sure you're using `await` before movement commands: +- Correct: `await my_marty.walk(2)` +- Incorrect: `my_marty.walk(2)` (this sends the command but does not wait for it) + +### Block Editor is empty +Blockly requires JavaScript to be enabled. If blocks disappear after saving, try clearing your browser's localStorage for localhost:3000. + +--- + +## Progression Checklist + +Use this to track your child's progress: + +**Beginner** +- [ ] Explored the 3D Marty on the home page +- [ ] Created a block program in the Block Editor +- [ ] Ran their first Python program +- [ ] Completed "Hello Marty!" tutorial +- [ ] Completed "Dance Moves" tutorial +- [ ] Solved all 3 beginner challenges + +**Intermediate** +- [ ] Completed "Sensing the World" tutorial +- [ ] Used an if-else statement +- [ ] Completed "Loops & Control Flow" tutorial +- [ ] Used a for loop +- [ ] Wrote their first function +- [ ] Solved all 3 intermediate challenges + +**Advanced** +- [ ] Completed "Python Power" tutorial +- [ ] Used lists and variables +- [ ] Created a multi-function program +- [ ] Solved all 3 advanced challenges +- [ ] Created their own original program from scratch! diff --git a/package-lock.json b/package-lock.json index 99a5a16..93e8f9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,14 @@ "name": "mini-marty", "version": "0.1.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "blockly": "^12.4.1", "next": "^16.1.6", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "three": "^0.183.2" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -20,6 +25,7 @@ "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.3", "@types/react": "^19.2.14", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.3", @@ -376,7 +382,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -585,6 +590,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1803,6 +1814,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2024,6 +2076,94 @@ "node": ">=18" } }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2776,6 +2916,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2858,6 +3004,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2889,16 +3041,65 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -3450,6 +3651,24 @@ "win32" ] }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -3613,6 +3832,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3640,7 +3865,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -3956,6 +4180,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3972,12 +4216,314 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" } }, + "node_modules/blockly": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-12.4.1.tgz", + "integrity": "sha512-OEF0r8cFMGDkQbX+PWTjifWTe9xi2QzpZS4rO2lYeQhZDWW3/eInklLSdoxAyEQCdfJhQqMTpBct13oDoc0GVQ==", + "license": "Apache-2.0", + "dependencies": { + "jsdom": "26.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/blockly/node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/blockly/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/blockly/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/blockly/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/blockly/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/blockly/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/blockly/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/blockly/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/blockly/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/blockly/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/blockly/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/blockly/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4036,6 +4582,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4093,7 +4663,20 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6" + } + }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" } }, "node_modules/caniuse-lite": { @@ -4183,11 +4766,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4249,7 +4849,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4331,7 +4930,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4349,7 +4947,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/deep-is": { @@ -4406,6 +5003,15 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4437,6 +5043,22 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4484,7 +5106,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5276,6 +5897,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5545,6 +6172,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5676,6 +6309,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5700,7 +6339,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -5714,7 +6352,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5724,6 +6361,38 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5734,6 +6403,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6047,7 +6722,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "license": "MIT" }, "node_modules/is-regex": { @@ -6206,7 +6886,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -6266,6 +6945,18 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6444,6 +7135,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -6762,6 +7462,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6800,6 +7510,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6827,6 +7550,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6874,11 +7612,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -7039,6 +7787,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7281,7 +8035,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7405,6 +8158,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7469,6 +8228,16 @@ "license": "MIT", "peer": true }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7485,7 +8254,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7550,6 +8318,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7612,7 +8395,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7715,6 +8497,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7794,11 +8582,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -7924,7 +8717,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7937,7 +8729,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8049,6 +8840,38 @@ "dev": true, "license": "MIT" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8268,11 +9091,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, "license": "MIT" }, "node_modules/tailwindcss": { @@ -8296,6 +9127,44 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8430,6 +9299,36 @@ "node": ">=20" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -8475,6 +9374,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8716,6 +9652,24 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -8917,7 +9871,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -8926,6 +9879,17 @@ "node": ">=18" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -8936,6 +9900,19 @@ "node": ">=20" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -8965,7 +9942,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9093,11 +10069,31 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -9107,7 +10103,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/yallist": { @@ -9152,6 +10147,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 085f8af..c62692e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,14 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "blockly": "^12.4.1", "next": "^16.1.6", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "three": "^0.183.2" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -29,6 +34,7 @@ "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.3", "@types/react": "^19.2.14", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^5.1.4", "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.3", diff --git a/src/app/block-editor/page.tsx b/src/app/block-editor/page.tsx new file mode 100644 index 0000000..7251d59 --- /dev/null +++ b/src/app/block-editor/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const BlocklyWorkspace = dynamic( + () => + import("@/features/blocks/BlocklyWorkspace").then( + (mod) => mod.BlocklyWorkspace, + ), + { + ssr: false, + loading: () => ( +
+ Loading Blockly workspace... +
+ ), + }, +); + +export default function BlockEditorPage() { + return ( +
+
+

+ Block Editor +

+

+ Drag and drop blocks to program Marty the robot. +

+
+
+ +
+
+ ); +} diff --git a/src/app/challenges/page.tsx b/src/app/challenges/page.tsx new file mode 100644 index 0000000..f2e8a6d --- /dev/null +++ b/src/app/challenges/page.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState } from "react"; +import { + CHALLENGES, + getChallengesByDifficulty, +} from "@/features/challenges/challenge-data"; +import type { Challenge } from "@/features/challenges/challenge-data"; + +const DIFFICULTY_COLORS = { + beginner: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + intermediate: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + advanced: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +const DIFFICULTIES = ["beginner", "intermediate", "advanced"] as const; + +export default function ChallengesPage() { + const [selectedChallenge, setSelectedChallenge] = + useState(null); + const [revealedHints, setRevealedHints] = useState(0); + const [filter, setFilter] = useState< + "all" | "beginner" | "intermediate" | "advanced" + >("all"); + + const challenges = + filter === "all" ? CHALLENGES : getChallengesByDifficulty(filter); + + if (selectedChallenge) { + return ( +
+
+ +
+

+ {selectedChallenge.title} +

+ + {selectedChallenge.difficulty} + +
+
+ +
+

+ {selectedChallenge.description} +

+ +
+

+ Starter Code: +

+
+              {selectedChallenge.starterCode}
+            
+

+ Copy this into the Python Editor and complete the TODOs! +

+
+ +
+

+ Hints ({revealedHints} of {selectedChallenge.hints.length}{" "} + revealed): +

+
+ {selectedChallenge.hints.map((hint, i) => ( +
+ {i < revealedHints ? ( + hint + ) : ( + + Hint {i + 1} — click below to reveal + + )} +
+ ))} +
+ {revealedHints < selectedChallenge.hints.length && ( + + )} +
+
+
+ ); + } + + return ( +
+

+ Challenges +

+

+ Test your programming skills! Solve challenges to practice what you have + learned. +

+ +
+ + {DIFFICULTIES.map((d) => ( + + ))} +
+ +
+ {challenges.map((challenge) => ( + + ))} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index f1d8c73..9955ae0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1 +1,12 @@ @import "tailwindcss"; + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +.dark { + color-scheme: dark; +} diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index 5803825..0c83666 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -1,9 +1,20 @@ import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/theme-context", () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/layout/AppShell", () => ({ + AppShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + import RootLayout from "./layout"; describe("RootLayout", () => { - it("renders children", () => { + it("renders children inside AppShell", () => { render(

Test content

@@ -11,5 +22,6 @@ describe("RootLayout", () => { { container: document.documentElement }, ); expect(screen.getByText("Test content")).toBeInTheDocument(); + expect(screen.getByTestId("app-shell")).toBeInTheDocument(); }); }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6252fe..6cd567b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata } from "next"; import "./globals.css"; +import { ThemeProvider } from "@/lib/theme-context"; +import { AppShell } from "@/components/layout/AppShell"; export const metadata: Metadata = { title: "Mini Marty", - description: "Visual programming environment for the Marty robot", + description: "Virtual programming environment for the Marty robot", }; export default function RootLayout({ @@ -13,7 +15,11 @@ export default function RootLayout({ }>) { return ( - {children} + + + {children} + + ); } diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index 4a8549d..79721eb 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -1,5 +1,14 @@ import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; + +vi.mock("next/dynamic", () => ({ + default: () => { + return function MockScene() { + return
Mock Scene
; + }; + }, +})); + import Home from "./page"; describe("Home page", () => { @@ -13,7 +22,14 @@ describe("Home page", () => { it("renders the description", () => { render(); expect( - screen.getByText(/visual programming environment/i), + screen.getByText(/virtual programming environment/i), ).toBeInTheDocument(); }); + + it("renders quick action links", () => { + render(); + expect(screen.getByText(/block editor/i)).toBeInTheDocument(); + expect(screen.getByText(/python editor/i)).toBeInTheDocument(); + expect(screen.getByText(/tutorials/i)).toBeInTheDocument(); + }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 7480457..4263551 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,105 @@ +"use client"; + +import Link from "next/link"; +import dynamic from "next/dynamic"; + +const MartyScene = dynamic( + () => + import("@/features/scene/components/MartyScene").then( + (mod) => mod.MartyScene, + ), + { ssr: false, loading: () => }, +); + export default function Home() { return ( -
-

Mini Marty

-

- Visual programming environment for the Marty robot +

+

+ Mini Marty +

+

+ A virtual programming environment to learn coding with Marty the Robot

-
+ +
+ +
+ +
+ + + +
+ +
+ + +
+ + ); +} + +function ScenePlaceholder() { + return ( +
+

Loading 3D scene...

+
+ ); +} + +function QuickAction({ + title, + description, + href, + icon, +}: { + readonly title: string; + readonly description: string; + readonly href: string; + readonly icon: string; +}) { + return ( + + +

+ {title} +

+

+ {description} +

+ ); } diff --git a/src/app/python-editor/page.tsx b/src/app/python-editor/page.tsx new file mode 100644 index 0000000..4ed4d42 --- /dev/null +++ b/src/app/python-editor/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useMemo, useEffect } from "react"; +import dynamic from "next/dynamic"; +import { PythonEditor } from "@/features/editor/components/PythonEditor"; +import { EditorToolbar } from "@/features/editor/components/EditorToolbar"; +import { usePythonEditor } from "@/features/editor/hooks/usePythonEditor"; +import { ConsoleOutput } from "@/features/python-runtime/components/ConsoleOutput"; +import { PyodideStatus } from "@/features/python-runtime/components/PyodideStatus"; +import { usePyodide } from "@/features/python-runtime/hooks/usePyodide"; +import { usePythonExecution } from "@/features/python-runtime/hooks/usePythonExecution"; +import { VirtualMarty } from "@/features/marty/virtual-marty"; + +const MartyScene = dynamic( + () => + import("@/features/scene/components/MartyScene").then( + (mod) => mod.MartyScene, + ), + { ssr: false }, +); + +export default function PythonEditorPage() { + const { code, setCode, clearCode, saveCode, loadCode } = usePythonEditor(); + const { + state: pyodideState, + error: pyodideError, + instance, + initialize, + } = usePyodide(); + + const marty = useMemo(() => new VirtualMarty(), []); + + const { isRunning, consoleEntries, run, stop, clearConsole } = + usePythonExecution(instance, marty); + + useEffect(() => { + initialize(); + }, [initialize]); + + const handleRun = () => { + run(code); + }; + + const handleStop = () => { + stop(); + }; + + const isReady = pyodideState === "ready"; + + return ( +
+
+

+ Python Editor +

+ +
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/app/tutorials/page.tsx b/src/app/tutorials/page.tsx new file mode 100644 index 0000000..28cf816 --- /dev/null +++ b/src/app/tutorials/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState } from "react"; +import { TUTORIALS } from "@/features/tutorials/tutorial-data"; +import type { Tutorial } from "@/features/tutorials/tutorial-data"; + +const DIFFICULTY_COLORS = { + beginner: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + intermediate: + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + advanced: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", +}; + +export default function TutorialsPage() { + const [selectedTutorial, setSelectedTutorial] = useState( + null, + ); + const [currentStep, setCurrentStep] = useState(0); + + if (selectedTutorial) { + const step = selectedTutorial.steps[currentStep]; + return ( +
+
+ +

+ {selectedTutorial.title} +

+

+ Step {currentStep + 1} of {selectedTutorial.steps.length} +

+
+ +
+

+ {step.title} +

+

+ {step.description} +

+ + {step.hint && ( +
+

+ Hint: {step.hint} +

+
+ )} + +
+

+ Code: +

+
+              {step.code}
+            
+

+ Copy this code into the Python Editor to try it out! +

+
+
+ +
+ +
+ {selectedTutorial.steps.map((_, i) => ( +
+ +
+
+ ); + } + + return ( +
+

+ Tutorials +

+

+ Follow step-by-step lessons to learn programming with Marty. Start with + the basics and work your way up! +

+ +
+ {TUTORIALS.map((tutorial) => ( + + ))} +
+ +
+

+ API Quick Reference +

+
+
+ {API_REFERENCE.map((item) => ( +
+ + {item.command} + + + {item.description} + +
+ ))} +
+
+
+
+ ); +} + +const API_REFERENCE = [ + { command: "walk(steps)", description: "Walk forward for N steps" }, + { command: "dance()", description: "Perform a dance routine" }, + { + command: 'kick("left"/"right")', + description: "Kick with the specified leg", + }, + { + command: 'slide("left"/"right")', + description: "Slide sideways in a direction", + }, + { + command: 'lean("left"/"right")', + description: "Lean in the specified direction", + }, + { command: "wiggle()", description: "Wiggle Marty's body" }, + { command: "circle_dance()", description: "Perform a circular dance" }, + { command: "celebrate()", description: "Celebrate with arms up!" }, + { command: "get_ready()", description: "Move to the ready position" }, + { command: "stand_straight()", description: "Stand upright" }, + { + command: 'eyes("normal"/"wide"/"angry"/"excited")', + description: "Set eye expression", + }, + { + command: "arms(left, right)", + description: "Set arm angles (-100 to 100)", + }, + { + command: 'foot_on_ground("left"/"right")', + description: "Check if foot is touching ground", + }, + { + command: "get_distance_sensor()", + description: "Get distance reading in cm", + }, + { + command: "get_accelerometer()", + description: "Get accelerometer {x, y, z} data", + }, + { command: "stop()", description: "Stop all current actions" }, + { command: "is_moving()", description: "Check if Marty is moving" }, + { + command: 'play_sound("excited"/"confused")', + description: "Play a sound effect", + }, +]; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..274126e --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Header } from "./Header"; +import { Sidebar } from "./Sidebar"; + +export function AppShell({ children }: { readonly children: React.ReactNode }) { + return ( +
+
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..850739d --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { NAV_ITEMS } from "@/lib/navigation"; +import { ThemeToggle } from "@/components/ui/ThemeToggle"; + +export function Header() { + const pathname = usePathname(); + + return ( +
+
+ + + + Mini Marty + + + +
+ +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..6d1a1ed --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { getSidebarSections } from "@/lib/sidebar-config"; + +export function Sidebar() { + const pathname = usePathname(); + const sections = getSidebarSections(pathname); + + return ( + + ); +} diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx new file mode 100644 index 0000000..582eeca --- /dev/null +++ b/src/components/ui/ThemeToggle.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useTheme } from "@/lib/theme-context"; + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/src/features/blocks/BlocklyWorkspace.tsx b/src/features/blocks/BlocklyWorkspace.tsx new file mode 100644 index 0000000..a92760f --- /dev/null +++ b/src/features/blocks/BlocklyWorkspace.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import * as Blockly from "blockly"; +import { MARTY_BLOCKS } from "./marty-blocks"; +import { TOOLBOX_CONFIG } from "./toolbox-config"; + +const STORAGE_KEY = "mini-marty-blocks"; + +export function BlocklyWorkspace() { + const containerRef = useRef(null); + const workspaceRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + Blockly.defineBlocksWithJsonArray( + MARTY_BLOCKS as unknown as Record[], + ); + + workspaceRef.current = Blockly.inject(containerRef.current, { + toolbox: + TOOLBOX_CONFIG as unknown as Blockly.utils.toolbox.ToolboxDefinition, + grid: { + spacing: 20, + length: 3, + colour: "#ccc", + snap: true, + }, + zoom: { + controls: true, + wheel: true, + startScale: 1.0, + maxScale: 3, + minScale: 0.3, + scaleSpeed: 1.2, + }, + trashcan: true, + }); + + return () => { + workspaceRef.current?.dispose(); + workspaceRef.current = null; + }; + }, []); + + const handleSave = useCallback(() => { + if (!workspaceRef.current) return; + const state = Blockly.serialization.workspaces.save(workspaceRef.current); + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + }, []); + + const handleLoad = useCallback(() => { + if (!workspaceRef.current) return; + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const state = JSON.parse(saved) as Record; + Blockly.serialization.workspaces.load(state, workspaceRef.current); + } catch (error) { + console.error("Failed to load workspace:", error); + } + } + }, []); + + const handleUndo = useCallback(() => { + workspaceRef.current?.undo(false); + }, []); + + const handleRedo = useCallback(() => { + workspaceRef.current?.undo(true); + }, []); + + return ( +
+
+ + +
+ + +
+
+
+ ); +} diff --git a/src/features/blocks/marty-blocks.ts b/src/features/blocks/marty-blocks.ts new file mode 100644 index 0000000..e9d17e2 --- /dev/null +++ b/src/features/blocks/marty-blocks.ts @@ -0,0 +1,344 @@ +export type BlockCategory = + | "Motion" + | "Sound" + | "Sensing" + | "Events" + | "Control"; + +export interface MartyBlockDefinition { + readonly type: string; + readonly category: BlockCategory; + readonly message0: string; + readonly args0?: readonly Record[]; + readonly message1?: string; + readonly args1?: readonly Record[]; + readonly colour: string; + readonly tooltip: string; + readonly previousStatement?: null; + readonly nextStatement?: null; + readonly output?: string | null; + readonly inputsInline?: boolean; +} + +const MOTION_COLOUR = "#4C97FF"; +const SOUND_COLOUR = "#CF63CF"; +const SENSING_COLOUR = "#5CB1D6"; +const EVENTS_COLOUR = "#FFBF00"; +const CONTROL_COLOUR = "#FFAB19"; + +export const MARTY_BLOCKS: readonly MartyBlockDefinition[] = [ + // Motion blocks + { + type: "marty_walk", + category: "Motion", + message0: "walk %1 steps", + args0: [{ type: "field_number", name: "STEPS", value: 2, min: 1 }], + colour: MOTION_COLOUR, + tooltip: "Make Marty walk forward.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_turn", + category: "Motion", + message0: "turn %1 degrees", + args0: [{ type: "field_number", name: "DEGREES", value: 90 }], + colour: MOTION_COLOUR, + tooltip: "Turn Marty by the specified angle.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_slide", + category: "Motion", + message0: "slide %1", + args0: [ + { + type: "field_dropdown", + name: "DIRECTION", + options: [ + ["left", "left"], + ["right", "right"], + ], + }, + ], + colour: MOTION_COLOUR, + tooltip: "Slide Marty sideways.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_kick", + category: "Motion", + message0: "kick %1", + args0: [ + { + type: "field_dropdown", + name: "LEG", + options: [ + ["left", "left"], + ["right", "right"], + ], + }, + ], + colour: MOTION_COLOUR, + tooltip: "Kick with the specified leg.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_dance", + category: "Motion", + message0: "dance", + colour: MOTION_COLOUR, + tooltip: "Make Marty perform a dance.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_circle_dance", + category: "Motion", + message0: "circle dance", + colour: MOTION_COLOUR, + tooltip: "Make Marty perform a rotational dance.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_wiggle", + category: "Motion", + message0: "wiggle", + colour: MOTION_COLOUR, + tooltip: "Make Marty wiggle.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_lean", + category: "Motion", + message0: "lean %1", + args0: [ + { + type: "field_dropdown", + name: "DIRECTION", + options: [ + ["left", "left"], + ["right", "right"], + ["forward", "forward"], + ["back", "back"], + ], + }, + ], + colour: MOTION_COLOUR, + tooltip: "Make Marty lean in a direction.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_celebrate", + category: "Motion", + message0: "celebrate", + colour: MOTION_COLOUR, + tooltip: "Make Marty celebrate!", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_get_ready", + category: "Motion", + message0: "get ready", + colour: MOTION_COLOUR, + tooltip: "Move Marty to the ready position.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_stand_straight", + category: "Motion", + message0: "stand straight", + colour: MOTION_COLOUR, + tooltip: "Stand Marty upright.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_eyes", + category: "Motion", + message0: "eyes %1", + args0: [ + { + type: "field_number", + name: "POSITION", + value: 0, + min: -100, + max: 100, + }, + ], + colour: MOTION_COLOUR, + tooltip: "Set eye position. 0=center, negative=left, positive=right.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_arms", + category: "Motion", + message0: "arms left %1 right %2", + args0: [ + { + type: "field_number", + name: "LEFT", + value: 0, + min: -100, + max: 100, + }, + { + type: "field_number", + name: "RIGHT", + value: 0, + min: -100, + max: 100, + }, + ], + colour: MOTION_COLOUR, + tooltip: "Set arm positions (-100 to 100).", + previousStatement: null, + nextStatement: null, + inputsInline: true, + }, + + // Sound blocks + { + type: "marty_play_sound", + category: "Sound", + message0: "play sound %1", + args0: [ + { + type: "field_dropdown", + name: "SOUND", + options: [ + ["excited", "excited"], + ["disbelief", "disbelief"], + ["confused", "confused"], + ], + }, + ], + colour: SOUND_COLOUR, + tooltip: "Play a sound effect.", + previousStatement: null, + nextStatement: null, + }, + + // Sensing blocks + { + type: "marty_foot_on_ground", + category: "Sensing", + message0: "%1 foot on ground?", + args0: [ + { + type: "field_dropdown", + name: "FOOT", + options: [ + ["left", "left"], + ["right", "right"], + ], + }, + ], + colour: SENSING_COLOUR, + tooltip: "Check if the specified foot is on the ground.", + output: "Boolean", + }, + { + type: "marty_get_distance", + category: "Sensing", + message0: "distance sensor", + colour: SENSING_COLOUR, + tooltip: "Get the distance sensor reading in cm.", + output: "Number", + }, + { + type: "marty_is_moving", + category: "Sensing", + message0: "is moving?", + colour: SENSING_COLOUR, + tooltip: "Check if Marty is currently moving.", + output: "Boolean", + }, + + // Event blocks + { + type: "marty_when_start", + category: "Events", + message0: "when program starts", + colour: EVENTS_COLOUR, + tooltip: "Run blocks when the program starts.", + nextStatement: null, + }, + { + type: "marty_when_key_pressed", + category: "Events", + message0: "when %1 key pressed", + args0: [ + { + type: "field_dropdown", + name: "KEY", + options: [ + ["space", "space"], + ["up arrow", "up"], + ["down arrow", "down"], + ["left arrow", "left"], + ["right arrow", "right"], + ], + }, + ], + colour: EVENTS_COLOUR, + tooltip: "Run blocks when a key is pressed.", + nextStatement: null, + }, + + // Control blocks + { + type: "marty_wait", + category: "Control", + message0: "wait %1 seconds", + args0: [{ type: "field_number", name: "SECONDS", value: 1, min: 0 }], + colour: CONTROL_COLOUR, + tooltip: "Wait for the specified number of seconds.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_repeat", + category: "Control", + message0: "repeat %1 times", + args0: [{ type: "field_number", name: "TIMES", value: 3, min: 1 }], + message1: "do %1", + args1: [{ type: "input_statement", name: "DO" }], + colour: CONTROL_COLOUR, + tooltip: "Repeat the enclosed blocks.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_if_else", + category: "Control", + message0: "if %1", + args0: [{ type: "input_value", name: "CONDITION", check: "Boolean" }], + message1: "do %1", + args1: [{ type: "input_statement", name: "DO" }], + colour: CONTROL_COLOUR, + tooltip: "If condition is true, run the enclosed blocks.", + previousStatement: null, + nextStatement: null, + }, + { + type: "marty_forever", + category: "Control", + message0: "forever", + message1: "do %1", + args1: [{ type: "input_statement", name: "DO" }], + colour: CONTROL_COLOUR, + tooltip: "Repeat the enclosed blocks forever.", + previousStatement: null, + nextStatement: null, + }, +]; diff --git a/src/features/blocks/toolbox-config.ts b/src/features/blocks/toolbox-config.ts new file mode 100644 index 0000000..dff3791 --- /dev/null +++ b/src/features/blocks/toolbox-config.ts @@ -0,0 +1,82 @@ +import { MARTY_BLOCKS, type BlockCategory } from "./marty-blocks"; + +interface ToolboxBlock { + readonly kind: "block"; + readonly type: string; +} + +interface ToolboxCategory { + readonly kind: "category"; + readonly name: string; + readonly colour: string; + readonly contents?: readonly ToolboxBlock[]; + readonly custom?: string; +} + +interface ToolboxConfig { + readonly kind: "categoryToolbox"; + readonly contents: readonly ToolboxCategory[]; +} + +const CATEGORY_COLOURS: Readonly> = { + Motion: "#4C97FF", + Sound: "#CF63CF", + Sensing: "#5CB1D6", + Events: "#FFBF00", + Control: "#FFAB19", +}; + +function blocksForCategory(category: BlockCategory): readonly ToolboxBlock[] { + return MARTY_BLOCKS.filter((b) => b.category === category).map((b) => ({ + kind: "block" as const, + type: b.type, + })); +} + +export const TOOLBOX_CONFIG: ToolboxConfig = { + kind: "categoryToolbox", + contents: [ + { + kind: "category", + name: "Motion", + colour: CATEGORY_COLOURS.Motion, + contents: blocksForCategory("Motion"), + }, + { + kind: "category", + name: "Sound", + colour: CATEGORY_COLOURS.Sound, + contents: blocksForCategory("Sound"), + }, + { + kind: "category", + name: "Sensing", + colour: CATEGORY_COLOURS.Sensing, + contents: blocksForCategory("Sensing"), + }, + { + kind: "category", + name: "Events", + colour: CATEGORY_COLOURS.Events, + contents: blocksForCategory("Events"), + }, + { + kind: "category", + name: "Control", + colour: CATEGORY_COLOURS.Control, + contents: blocksForCategory("Control"), + }, + { + kind: "category", + name: "Variables", + colour: "#FF8C1A", + custom: "VARIABLE", + }, + { + kind: "category", + name: "Operators", + colour: "#59C059", + custom: "PROCEDURE", + }, + ], +}; diff --git a/src/features/challenges/challenge-data.ts b/src/features/challenges/challenge-data.ts new file mode 100644 index 0000000..b76e0ab --- /dev/null +++ b/src/features/challenges/challenge-data.ts @@ -0,0 +1,219 @@ +export interface Challenge { + readonly id: string; + readonly title: string; + readonly description: string; + readonly difficulty: "beginner" | "intermediate" | "advanced"; + readonly starterCode: string; + readonly hints: readonly string[]; + readonly expectedActions: readonly string[]; +} + +export const CHALLENGES: readonly Challenge[] = [ + // --- Beginner --- + { + id: "first-walk", + title: "First Walk", + description: + "Make Marty get ready and walk 4 steps. Don't forget to stand straight at the end!", + difficulty: "beginner", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") + +# TODO: Make Marty get ready +# TODO: Walk 4 steps +# TODO: Stand straight`, + hints: [ + "Use get_ready() to prepare Marty", + "walk() takes the number of steps as an argument", + "Finish with stand_straight()", + ], + expectedActions: ["get_ready", "walk", "stand_straight"], + }, + { + id: "left-right-kick", + title: "Left-Right Kick", + description: + "Make Marty kick with the left foot, then the right foot, then celebrate!", + difficulty: "beginner", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# TODO: Kick left +# TODO: Kick right +# TODO: Celebrate!`, + hints: [ + 'kick() takes "left" or "right" as a parameter', + "Use celebrate() at the end", + ], + expectedActions: ["kick", "kick", "celebrate"], + }, + { + id: "dance-party", + title: "Dance Party", + description: + "Create a dance routine using at least 3 different dance moves: dance, wiggle, and circle_dance.", + difficulty: "beginner", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# Create your dance routine below!`, + hints: [ + "Try dance(), wiggle(), and circle_dance()", + "End with celebrate() for a grand finish", + ], + expectedActions: ["dance", "wiggle", "circle_dance"], + }, + + // --- Intermediate --- + { + id: "sensor-check", + title: "Sensor Check", + description: + "Read all of Marty's sensors and print the values: foot sensors, distance, and accelerometer.", + difficulty: "intermediate", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# TODO: Check if left foot is on the ground and print the result +# TODO: Check if right foot is on the ground and print the result +# TODO: Read the distance sensor and print it +# TODO: Read the accelerometer and print x, y, z values`, + hints: [ + 'Use foot_on_ground("left") and foot_on_ground("right")', + "get_distance_sensor() returns a number in cm", + 'get_accelerometer() returns a dict with "x", "y", "z" keys', + ], + expectedActions: [], + }, + { + id: "loop-walker", + title: "Loop Walker", + description: + "Use a for loop to make Marty walk 2 steps, then wiggle, 5 times in a row. Print which iteration you're on each time.", + difficulty: "intermediate", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# TODO: Use a for loop to repeat 5 times: +# - Print the current iteration number +# - Walk 2 steps +# - Wiggle`, + hints: [ + "Use range(5) for the loop", + "Remember to use 'await' with walk() and wiggle()", + "Use f-strings for printing: f'Round {i+1}'", + ], + expectedActions: ["walk", "wiggle"], + }, + { + id: "smart-marty", + title: "Smart Marty", + description: + "Write a program that checks the distance sensor. If distance > 50, walk forward. If 20-50, slide left. If < 20, kick!", + difficulty: "intermediate", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +distance = my_marty.get_distance_sensor() +print(f"Distance: {distance} cm") + +# TODO: Use if/elif/else to decide what Marty should do +# > 50: walk(3) +# 20-50: slide("left") +# < 20: kick("right")`, + hints: [ + "Use if, elif, and else for three conditions", + "Compare distance with > and <=", + ], + expectedActions: [], + }, + + // --- Advanced --- + { + id: "choreographer", + title: "Robot Choreographer", + description: + "Define a list of at least 6 moves and use a loop to execute them all. Include a mix of movements, eyes, and arms.", + difficulty: "advanced", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# TODO: Create a list of move names (strings) +# TODO: Loop through the list and call each move +# Hint: Use getattr(my_marty, move_name) to call methods by string name`, + hints: [ + "Create a list like: moves = ['walk', 'dance', 'wiggle', ...]", + "getattr(my_marty, name) returns the method", + "Some methods need arguments (walk needs steps, kick needs a side)", + ], + expectedActions: [], + }, + { + id: "custom-functions", + title: "Function Factory", + description: + "Create 3 custom async functions: a greeting routine, a workout routine, and a finale. Call them in sequence.", + difficulty: "advanced", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") + +# TODO: Define async def greeting(): with walk + lean moves +# TODO: Define async def workout(): with kick + slide + wiggle +# TODO: Define async def finale(): with dance + celebrate + +# TODO: Call all three functions in order`, + hints: [ + "Use 'async def' to define each function", + "Use 'await' when calling both the functions and the Marty commands inside them", + "Each function should have at least 2-3 Marty commands", + ], + expectedActions: [], + }, + { + id: "performance", + title: "The Marty Show", + description: + "Create a full 3-act performance with an intro, main acts, and a grand finale. Use variables, loops, functions, eye expressions, and at least 8 different Marty commands.", + difficulty: "advanced", + starterCode: `from martypy import Marty + +my_marty = Marty("virtual") + +# Act 1: Introduction +# Act 2: Main Performance +# Act 3: Grand Finale + +# Be creative! Use everything you've learned.`, + hints: [ + "Plan your performance with a clear structure", + "Mix movement commands with eye expressions for emotion", + "Use loops for repeated patterns", + "End with a big celebrate()!", + ], + expectedActions: [], + }, +]; + +export function getChallengesByDifficulty( + difficulty: Challenge["difficulty"], +): readonly Challenge[] { + return CHALLENGES.filter((c) => c.difficulty === difficulty); +} + +export function getChallengeById(id: string): Challenge | undefined { + return CHALLENGES.find((c) => c.id === id); +} diff --git a/src/features/editor/components/EditorToolbar.tsx b/src/features/editor/components/EditorToolbar.tsx new file mode 100644 index 0000000..28ec775 --- /dev/null +++ b/src/features/editor/components/EditorToolbar.tsx @@ -0,0 +1,59 @@ +interface EditorToolbarProps { + readonly onRun: () => void; + readonly onStop: () => void; + readonly onClear: () => void; + readonly onSave: () => void; + readonly onLoad: () => void; + readonly isRunning: boolean; +} + +export function EditorToolbar({ + onRun, + onStop, + onClear, + onSave, + onLoad, + isRunning, +}: EditorToolbarProps) { + return ( +
+ + +
+ + + +
+ ); +} diff --git a/src/features/editor/components/PythonEditor.tsx b/src/features/editor/components/PythonEditor.tsx new file mode 100644 index 0000000..3f3b011 --- /dev/null +++ b/src/features/editor/components/PythonEditor.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Editor from "@monaco-editor/react"; +import { useTheme } from "@/lib/theme-context"; + +interface PythonEditorProps { + readonly value: string; + readonly onChange: (value: string) => void; +} + +export function PythonEditor({ value, onChange }: PythonEditorProps) { + const { theme } = useTheme(); + const monacoTheme = theme === "dark" ? "vs-dark" : "vs"; + + return ( + onChange(v ?? "")} + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: "on", + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 4, + wordWrap: "on", + }} + /> + ); +} diff --git a/src/features/editor/hooks/usePythonEditor.ts b/src/features/editor/hooks/usePythonEditor.ts new file mode 100644 index 0000000..f5fe101 --- /dev/null +++ b/src/features/editor/hooks/usePythonEditor.ts @@ -0,0 +1,43 @@ +import { useState, useCallback } from "react"; +import { STARTER_TEMPLATE } from "../martypy-completions"; + +const STORAGE_KEY = "mini-marty-python-code"; + +export function usePythonEditor() { + const [code, setCode] = useState(STARTER_TEMPLATE); + const [isRunning, setIsRunning] = useState(false); + + const clearCode = useCallback(() => { + setCode(STARTER_TEMPLATE); + }, []); + + const saveCode = useCallback(() => { + localStorage.setItem(STORAGE_KEY, code); + }, [code]); + + const loadCode = useCallback(() => { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved !== null) { + setCode(saved); + } + }, []); + + const run = useCallback(() => { + setIsRunning(true); + }, []); + + const stop = useCallback(() => { + setIsRunning(false); + }, []); + + return { + code, + setCode, + isRunning, + clearCode, + saveCode, + loadCode, + run, + stop, + } as const; +} diff --git a/src/features/editor/martypy-completions.ts b/src/features/editor/martypy-completions.ts new file mode 100644 index 0000000..9b81408 --- /dev/null +++ b/src/features/editor/martypy-completions.ts @@ -0,0 +1,128 @@ +export interface MartyCompletion { + readonly label: string; + readonly insertText: string; + readonly detail: string; +} + +export const MARTYPY_COMPLETIONS: readonly MartyCompletion[] = [ + // Movement + { + label: "walk", + insertText: "my_marty.walk(${1:2})", + detail: "Walk forward for N steps", + }, + { + label: "dance", + insertText: "my_marty.dance()", + detail: "Perform a dance routine", + }, + { + label: "kick", + insertText: 'my_marty.kick(${1:"left"})', + detail: "Kick with left or right leg", + }, + { + label: "slide", + insertText: 'my_marty.slide(${1:"left"}, ${2:1})', + detail: "Slide in a direction for N steps", + }, + { + label: "lean", + insertText: 'my_marty.lean(${1:"left"}, ${2:500})', + detail: "Lean in a direction for N milliseconds", + }, + { + label: "wiggle", + insertText: "my_marty.wiggle()", + detail: "Wiggle Marty's body", + }, + { + label: "circle_dance", + insertText: 'my_marty.circle_dance(${1:"left"}, ${2:1})', + detail: "Perform a circular dance", + }, + { + label: "celebrate", + insertText: "my_marty.celebrate()", + detail: "Celebrate with arms up and wiggle", + }, + + // Poses + { + label: "get_ready", + insertText: "my_marty.get_ready()", + detail: "Move to ready/neutral pose", + }, + { + label: "stand_straight", + insertText: "my_marty.stand_straight()", + detail: "Stand up straight", + }, + + // Joint control + { + label: "eyes", + insertText: 'my_marty.eyes(${1:"normal"})', + detail: "Set eye expression: normal, wide, angry, excited", + }, + { + label: "arms", + insertText: "my_marty.arms(${1:0}, ${2:0}, ${3:500})", + detail: "Set arm positions (left angle, right angle, duration)", + }, + { + label: "move_joint", + insertText: 'my_marty.move_joint(${1:"left_hip"}, ${2:0}, ${3:500})', + detail: "Move a specific joint to an angle", + }, + + // Sensors + { + label: "foot_on_ground", + insertText: 'my_marty.foot_on_ground(${1:"left"})', + detail: "Check if foot is touching the ground", + }, + { + label: "get_distance_sensor", + insertText: "my_marty.get_distance_sensor()", + detail: "Get distance sensor reading", + }, + { + label: "get_accelerometer", + insertText: 'my_marty.get_accelerometer(${1:"x"})', + detail: "Get accelerometer reading for axis (x, y, z)", + }, + + // Control + { + label: "stop", + insertText: "my_marty.stop()", + detail: "Stop all current actions", + }, + { + label: "is_moving", + insertText: "my_marty.is_moving()", + detail: "Check if Marty is currently moving", + }, + + // Sound + { + label: "play_sound", + insertText: 'my_marty.play_sound(${1:"excited"})', + detail: "Play a sound effect", + }, +] as const; + +export const STARTER_TEMPLATE = `# Mini Marty - Python Editor +# Write Python code to control your virtual Marty robot! + +from martypy import Marty + +# Create a virtual Marty instance +my_marty = Marty("virtual") + +# Try some commands: +my_marty.get_ready() +my_marty.walk(2) +my_marty.dance() +`; diff --git a/src/features/marty/command-queue.ts b/src/features/marty/command-queue.ts new file mode 100644 index 0000000..46a739e --- /dev/null +++ b/src/features/marty/command-queue.ts @@ -0,0 +1,125 @@ +import type { + MartyCommand, + ExecutionMode, + QueuedCommand, + CommandStartEvent, + CommandCompleteEvent, +} from "./types"; + +type CommandStartListener = (event: CommandStartEvent) => void; +type CommandCompleteListener = (event: CommandCompleteEvent) => void; + +let nextId = 0; + +function generateId(): string { + nextId += 1; + return `cmd-${nextId}`; +} + +export class CommandQueue { + private queue: QueuedCommand[] = []; + private processing = false; + private startListeners: CommandStartListener[] = []; + private completeListeners: CommandCompleteListener[] = []; + + enqueue(command: MartyCommand, mode: ExecutionMode): Promise { + const id = generateId(); + const queued: QueuedCommand = { + id, + command, + status: "pending", + blocking: mode === "blocking", + createdAt: Date.now(), + }; + this.queue = [...this.queue, queued]; + + if (mode === "non-blocking") { + this.emitStart(queued); + this.scheduleComplete(queued); + return Promise.resolve(); + } + + const promise = new Promise((resolve) => { + this.processQueue(resolve); + }); + + return promise; + } + + size(): number { + return this.queue.length; + } + + isProcessing(): boolean { + return this.processing; + } + + clear(): void { + this.queue = []; + } + + onCommandStart(listener: CommandStartListener): void { + this.startListeners = [...this.startListeners, listener]; + } + + onCommandComplete(listener: CommandCompleteListener): void { + this.completeListeners = [...this.completeListeners, listener]; + } + + private processQueue(resolve: () => void): void { + if (this.processing) { + const checkInterval = setInterval(() => { + if (!this.processing) { + clearInterval(checkInterval); + this.processQueue(resolve); + } + }, 10); + return; + } + + const current = this.queue[0]; + if (!current) { + resolve(); + return; + } + + this.processing = true; + this.emitStart(current); + + setTimeout(() => { + this.queue = this.queue.filter((q) => q.id !== current.id); + this.processing = false; + this.emitComplete(current); + resolve(); + }, current.command.duration); + } + + private scheduleComplete(queued: QueuedCommand): void { + setTimeout(() => { + this.queue = this.queue.filter((q) => q.id !== queued.id); + this.emitComplete(queued); + }, queued.command.duration); + } + + private emitStart(queued: QueuedCommand): void { + const event: CommandStartEvent = { + type: "commandStart", + commandId: queued.id, + command: queued.command, + }; + for (const listener of this.startListeners) { + listener(event); + } + } + + private emitComplete(queued: QueuedCommand): void { + const event: CommandCompleteEvent = { + type: "commandComplete", + commandId: queued.id, + command: queued.command, + }; + for (const listener of this.completeListeners) { + listener(event); + } + } +} diff --git a/src/features/marty/event-emitter.ts b/src/features/marty/event-emitter.ts new file mode 100644 index 0000000..9682321 --- /dev/null +++ b/src/features/marty/event-emitter.ts @@ -0,0 +1,36 @@ +import type { MartyEventMap, MartyEventType } from "./types"; + +type Listener = (event: MartyEventMap[T]) => void; + +export class MartyEventEmitter { + private readonly listeners: { + [K in MartyEventType]?: Array>; + } = {}; + + on(type: T, listener: Listener): void { + const list = (this.listeners[type] ?? []) as Array>; + this.listeners[type] = [...list, listener] as (typeof this.listeners)[T]; + } + + off(type: T, listener: Listener): void { + const list = this.listeners[type] as Array> | undefined; + if (!list) return; + this.listeners[type] = list.filter( + (l) => l !== listener, + ) as (typeof this.listeners)[T]; + } + + emit(type: T, event: MartyEventMap[T]): void { + const list = this.listeners[type] as Array> | undefined; + if (!list) return; + for (const listener of list) { + listener(event); + } + } + + removeAllListeners(): void { + for (const key of Object.keys(this.listeners) as MartyEventType[]) { + delete this.listeners[key]; + } + } +} diff --git a/src/features/marty/types.ts b/src/features/marty/types.ts new file mode 100644 index 0000000..b3adacd --- /dev/null +++ b/src/features/marty/types.ts @@ -0,0 +1,110 @@ +export type Direction = "left" | "right"; +export type Leg = "left" | "right"; +export type EyePosition = "normal" | "wide" | "angry" | "excited" | "squint"; +export type FootSide = "left" | "right"; + +export type CommandStatus = "pending" | "running" | "completed" | "error"; + +export interface MovementCommand { + readonly type: "movement"; + readonly action: + | "walk" + | "dance" + | "kick" + | "slide" + | "lean" + | "wiggle" + | "circle_dance" + | "celebrate" + | "get_ready" + | "stand_straight"; + readonly params: Readonly>; + readonly duration: number; +} + +export interface JointCommand { + readonly type: "joint"; + readonly action: "eyes" | "arms" | "move_joint"; + readonly params: Readonly>; + readonly duration: number; +} + +export interface SoundCommand { + readonly type: "sound"; + readonly action: "play_sound"; + readonly params: Readonly>; + readonly duration: number; +} + +export interface StatusCommand { + readonly type: "status"; + readonly action: "stop" | "resume" | "hold_position"; + readonly params: Readonly>; + readonly duration: number; +} + +export type MartyCommand = + | MovementCommand + | JointCommand + | SoundCommand + | StatusCommand; + +export interface QueuedCommand { + readonly id: string; + readonly command: MartyCommand; + readonly status: CommandStatus; + readonly blocking: boolean; + readonly createdAt: number; +} + +export type MartyEventType = + | "commandStart" + | "commandComplete" + | "commandError" + | "statusChange"; + +export interface CommandStartEvent { + readonly type: "commandStart"; + readonly commandId: string; + readonly command: MartyCommand; +} + +export interface CommandCompleteEvent { + readonly type: "commandComplete"; + readonly commandId: string; + readonly command: MartyCommand; +} + +export interface CommandErrorEvent { + readonly type: "commandError"; + readonly commandId: string; + readonly command: MartyCommand; + readonly error: string; +} + +export interface StatusChangeEvent { + readonly type: "statusChange"; + readonly isMoving: boolean; + readonly isPaused: boolean; +} + +export type MartyEvent = + | CommandStartEvent + | CommandCompleteEvent + | CommandErrorEvent + | StatusChangeEvent; + +export type MartyEventMap = { + readonly commandStart: CommandStartEvent; + readonly commandComplete: CommandCompleteEvent; + readonly commandError: CommandErrorEvent; + readonly statusChange: StatusChangeEvent; +}; + +export interface SensorData { + readonly footOnGround: Readonly>; + readonly distance: number; + readonly accelerometer: Readonly<{ x: number; y: number; z: number }>; +} + +export type ExecutionMode = "blocking" | "non-blocking"; diff --git a/src/features/marty/virtual-marty.ts b/src/features/marty/virtual-marty.ts new file mode 100644 index 0000000..40918b0 --- /dev/null +++ b/src/features/marty/virtual-marty.ts @@ -0,0 +1,266 @@ +import type { + Direction, + Leg, + EyePosition, + FootSide, + ExecutionMode, + MartyCommand, + MartyEventType, + MartyEventMap, + SensorData, +} from "./types"; +import { MartyEventEmitter } from "./event-emitter"; +import { CommandQueue } from "./command-queue"; + +const DEFAULT_SENSOR_DATA: SensorData = { + footOnGround: { left: true, right: true }, + distance: 100, + accelerometer: { x: 0, y: -9.8, z: 0 }, +}; + +export class VirtualMarty { + private readonly emitter = new MartyEventEmitter(); + private readonly queue = new CommandQueue(); + private executionMode: ExecutionMode = "blocking"; + private moving = false; + private paused = false; + private sensorData: SensorData = { ...DEFAULT_SENSOR_DATA }; + + constructor() { + this.queue.onCommandStart((event) => { + this.moving = true; + this.emitter.emit("commandStart", { + type: "commandStart", + commandId: event.commandId, + command: event.command, + }); + this.emitter.emit("statusChange", { + type: "statusChange", + isMoving: true, + isPaused: this.paused, + }); + }); + + this.queue.onCommandComplete((event) => { + this.moving = false; + this.emitter.emit("commandComplete", { + type: "commandComplete", + commandId: event.commandId, + command: event.command, + }); + this.emitter.emit("statusChange", { + type: "statusChange", + isMoving: false, + isPaused: this.paused, + }); + }); + } + + // --- Event API --- + + on( + type: T, + listener: (event: MartyEventMap[T]) => void, + ): void { + this.emitter.on(type, listener); + } + + off( + type: T, + listener: (event: MartyEventMap[T]) => void, + ): void { + this.emitter.off(type, listener); + } + + // --- Execution Mode --- + + getExecutionMode(): ExecutionMode { + return this.executionMode; + } + + setExecutionMode(mode: ExecutionMode): void { + this.executionMode = mode; + } + + // --- Movement Commands --- + + walk(steps = 2, speed = 50): Promise { + return this.enqueueCommand({ + type: "movement", + action: "walk", + params: { steps, speed }, + duration: 1000, + }); + } + + dance(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "dance", + params: {}, + duration: 2000, + }); + } + + kick(leg: Leg = "right"): Promise { + return this.enqueueCommand({ + type: "movement", + action: "kick", + params: { leg }, + duration: 1000, + }); + } + + slide(direction: Direction = "left", steps = 1): Promise { + return this.enqueueCommand({ + type: "movement", + action: "slide", + params: { direction, steps }, + duration: 1000, + }); + } + + lean(direction: Direction = "left", amount = 30): Promise { + return this.enqueueCommand({ + type: "movement", + action: "lean", + params: { direction, amount }, + duration: 500, + }); + } + + wiggle(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "wiggle", + params: {}, + duration: 1500, + }); + } + + circle_dance(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "circle_dance", + params: {}, + duration: 3000, + }); + } + + celebrate(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "celebrate", + params: {}, + duration: 2000, + }); + } + + get_ready(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "get_ready", + params: {}, + duration: 500, + }); + } + + stand_straight(): Promise { + return this.enqueueCommand({ + type: "movement", + action: "stand_straight", + params: {}, + duration: 500, + }); + } + + // --- Joint Control --- + + eyes(position: EyePosition = "normal"): Promise { + return this.enqueueCommand({ + type: "joint", + action: "eyes", + params: { position }, + duration: 300, + }); + } + + arms(left = 0, right = 0): Promise { + return this.enqueueCommand({ + type: "joint", + action: "arms", + params: { left, right }, + duration: 500, + }); + } + + move_joint(id: number, angle: number, time: number): Promise { + return this.enqueueCommand({ + type: "joint", + action: "move_joint", + params: { id, angle, time }, + duration: time, + }); + } + + // --- Status --- + + is_moving(): boolean { + return this.moving; + } + + is_paused(): boolean { + return this.paused; + } + + stop(): Promise { + this.moving = false; + this.queue.clear(); + return Promise.resolve(); + } + + resume(): Promise { + this.paused = false; + return Promise.resolve(); + } + + hold_position(): Promise { + return this.enqueueCommand({ + type: "status", + action: "hold_position", + params: {}, + duration: 100, + }); + } + + // --- Sensors (simulated) --- + + foot_on_ground(foot: FootSide): boolean { + return this.sensorData.footOnGround[foot]; + } + + get_distance_sensor(): number { + return this.sensorData.distance; + } + + get_accelerometer(): Readonly<{ x: number; y: number; z: number }> { + return { ...this.sensorData.accelerometer }; + } + + // --- Sound --- + + play_sound(name: string): Promise { + return this.enqueueCommand({ + type: "sound", + action: "play_sound", + params: { name }, + duration: 2000, + }); + } + + // --- Internal --- + + private enqueueCommand(command: MartyCommand): Promise { + return this.queue.enqueue(command, this.executionMode); + } +} diff --git a/src/features/python-runtime/components/ConsoleOutput.tsx b/src/features/python-runtime/components/ConsoleOutput.tsx new file mode 100644 index 0000000..615fe33 --- /dev/null +++ b/src/features/python-runtime/components/ConsoleOutput.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from "react"; +import type { ConsoleEntry } from "../types"; + +interface ConsoleOutputProps { + readonly entries: readonly ConsoleEntry[]; + readonly onClear: () => void; +} + +function getEntryClassName(type: ConsoleEntry["type"]): string { + switch (type) { + case "stderr": + return "text-red-400"; + case "info": + return "text-blue-400"; + case "stdout": + default: + return "text-green-300"; + } +} + +export function ConsoleOutput({ entries, onClear }: ConsoleOutputProps) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [entries]); + + return ( +
+
+ Console + +
+
+ {entries.length === 0 ? ( + + Python output will appear here... + + ) : ( + entries.map((entry) => ( +
+ {entry.text} +
+ )) + )} +
+
+ ); +} diff --git a/src/features/python-runtime/components/PyodideStatus.tsx b/src/features/python-runtime/components/PyodideStatus.tsx new file mode 100644 index 0000000..b5c1aa3 --- /dev/null +++ b/src/features/python-runtime/components/PyodideStatus.tsx @@ -0,0 +1,74 @@ +import type { PyodideLoadingState } from "../types"; + +interface PyodideStatusProps { + readonly state: PyodideLoadingState; + readonly error: string | null; + readonly onRetry: () => void; +} + +function getStatusMessage(state: PyodideLoadingState): string { + switch (state) { + case "idle": + return "Python runtime not started"; + case "loading": + return "Loading Python runtime..."; + case "ready": + return "Python ready"; + case "error": + return "Failed to load Python runtime"; + } +} + +function getStatusColor(state: PyodideLoadingState): string { + switch (state) { + case "idle": + return "bg-gray-500"; + case "loading": + return "bg-yellow-500 animate-pulse"; + case "ready": + return "bg-green-500"; + case "error": + return "bg-red-500"; + } +} + +export function PyodideStatus({ state, error, onRetry }: PyodideStatusProps) { + return ( +
+
+ + {getStatusMessage(state)} + + {state === "loading" && ( + + (this may take a few seconds) + + )} + {state === "error" && ( +
+ {error && ( + + {error} + + )} + +
+ )} +
+ ); +} diff --git a/src/features/python-runtime/hooks/usePyodide.ts b/src/features/python-runtime/hooks/usePyodide.ts new file mode 100644 index 0000000..c6c48da --- /dev/null +++ b/src/features/python-runtime/hooks/usePyodide.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useCallback } from "react"; +import type { PyodideLoadingState } from "../types"; +import type { PyodideInstance } from "../pyodide-service"; +import { + loadPyodide, + onStateChange, + getLoadingState, + getInstance, +} from "../pyodide-service"; + +export interface UsePyodideResult { + readonly state: PyodideLoadingState; + readonly error: string | null; + readonly instance: PyodideInstance | null; + readonly initialize: () => Promise; +} + +export function usePyodide(): UsePyodideResult { + const [state, setState] = useState(getLoadingState); + const [error, setError] = useState(null); + const [instance, setInstance] = useState(getInstance); + + useEffect(() => { + const unsubscribe = onStateChange( + (newState: PyodideLoadingState, err?: string) => { + setState(newState); + if (err) setError(err); + if (newState === "ready") { + setInstance(getInstance()); + } + }, + ); + + return unsubscribe; + }, []); + + const initialize = useCallback(async () => { + try { + setError(null); + const pyodide = await loadPyodide(); + setInstance(pyodide); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to initialize Pyodide"; + setError(message); + } + }, []); + + return { state, error, instance, initialize } as const; +} diff --git a/src/features/python-runtime/hooks/usePythonExecution.ts b/src/features/python-runtime/hooks/usePythonExecution.ts new file mode 100644 index 0000000..dc4be3e --- /dev/null +++ b/src/features/python-runtime/hooks/usePythonExecution.ts @@ -0,0 +1,88 @@ +import { useState, useCallback, useRef } from "react"; +import type { ConsoleEntry } from "../types"; +import type { PyodideInstance } from "../pyodide-service"; +import { VirtualMarty } from "@/features/marty/virtual-marty"; +import { registerMartyModule, executePythonCode } from "../python-executor"; + +export interface UsePythonExecutionResult { + readonly isRunning: boolean; + readonly consoleEntries: readonly ConsoleEntry[]; + readonly lastError: string | null; + readonly run: (code: string) => Promise; + readonly stop: () => void; + readonly clearConsole: () => void; +} + +export function usePythonExecution( + pyodide: PyodideInstance | null, + marty: VirtualMarty | null, +): UsePythonExecutionResult { + const [isRunning, setIsRunning] = useState(false); + const [consoleEntries, setConsoleEntries] = useState( + [], + ); + const [lastError, setLastError] = useState(null); + const registeredRef = useRef(false); + const abortRef = useRef(false); + + const addEntry = useCallback((entry: ConsoleEntry) => { + setConsoleEntries((prev) => [...prev, entry]); + }, []); + + const clearConsole = useCallback(() => { + setConsoleEntries([]); + setLastError(null); + }, []); + + const run = useCallback( + async (code: string) => { + if (!pyodide || !marty) return; + if (isRunning) return; + + abortRef.current = false; + setIsRunning(true); + setLastError(null); + + try { + if (!registeredRef.current) { + await registerMartyModule(pyodide, marty); + registeredRef.current = true; + } + + if (abortRef.current) return; + + const result = await executePythonCode(pyodide, code, { + onStdout: addEntry, + onStderr: addEntry, + }); + + if (!result.success && result.error) { + setLastError(result.error); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Execution failed"; + setLastError(message); + } finally { + setIsRunning(false); + } + }, + [pyodide, marty, isRunning, addEntry], + ); + + const stop = useCallback(() => { + abortRef.current = true; + if (marty) { + marty.stop(); + } + setIsRunning(false); + }, [marty]); + + return { + isRunning, + consoleEntries, + lastError, + run, + stop, + clearConsole, + } as const; +} diff --git a/src/features/python-runtime/martypy-module.ts b/src/features/python-runtime/martypy-module.ts new file mode 100644 index 0000000..14165e9 --- /dev/null +++ b/src/features/python-runtime/martypy-module.ts @@ -0,0 +1,146 @@ +import type { + Direction, + Leg, + EyePosition, + FootSide, +} from "@/features/marty/types"; +import { VirtualMarty } from "@/features/marty/virtual-marty"; + +export function createMartyBridge(marty: VirtualMarty): object { + return { + walk: (steps = 2, speed = 50) => marty.walk(steps, speed), + dance: () => marty.dance(), + kick: (leg: Leg = "right") => marty.kick(leg), + slide: (direction: Direction = "left", steps = 1) => + marty.slide(direction, steps), + lean: (direction: Direction = "left", amount = 30) => + marty.lean(direction, amount), + wiggle: () => marty.wiggle(), + circle_dance: () => marty.circle_dance(), + celebrate: () => marty.celebrate(), + get_ready: () => marty.get_ready(), + stand_straight: () => marty.stand_straight(), + eyes: (position: EyePosition = "normal") => marty.eyes(position), + arms: (left = 0, right = 0) => marty.arms(left, right), + move_joint: (id: number, angle: number, time: number) => + marty.move_joint(id, angle, time), + stop: () => marty.stop(), + is_moving: () => marty.is_moving(), + is_paused: () => marty.is_paused(), + resume: () => marty.resume(), + hold_position: () => marty.hold_position(), + foot_on_ground: (foot: FootSide = "left") => marty.foot_on_ground(foot), + get_distance_sensor: () => marty.get_distance_sensor(), + get_accelerometer: () => marty.get_accelerometer(), + play_sound: (name: string) => marty.play_sound(name), + }; +} + +export const MARTYPY_MODULE_CODE = ` +import sys +import types +import pyodide_js +import asyncio + +# Create the martypy module and register it in sys.modules +# so 'from martypy import Marty' works as expected +_martypy_mod = types.ModuleType("martypy") +_martypy_mod.__package__ = "martypy" + +class Marty: + def __init__(self, connection_type="virtual"): + if connection_type != "virtual": + raise ValueError("Only 'virtual' connection type is supported in Mini Marty") + self._bridge = pyodide_js._marty_bridge + self._connected = True + + async def walk(self, steps=2, speed=50): + await self._bridge.walk(steps, speed) + + async def dance(self): + await self._bridge.dance() + + async def kick(self, leg="right"): + await self._bridge.kick(leg) + + async def slide(self, direction="left", steps=1): + await self._bridge.slide(direction, steps) + + async def lean(self, direction="left", amount=30): + await self._bridge.lean(direction, amount) + + async def wiggle(self): + await self._bridge.wiggle() + + async def circle_dance(self): + await self._bridge.circle_dance() + + async def celebrate(self): + await self._bridge.celebrate() + + async def get_ready(self): + await self._bridge.get_ready() + + async def stand_straight(self): + await self._bridge.stand_straight() + + async def eyes(self, position="normal"): + await self._bridge.eyes(position) + + async def arms(self, left=0, right=0): + await self._bridge.arms(left, right) + + async def move_joint(self, joint_id, angle, time_ms): + await self._bridge.move_joint(joint_id, angle, time_ms) + + async def stop(self): + await self._bridge.stop() + + def is_moving(self): + return self._bridge.is_moving() + + def is_paused(self): + return self._bridge.is_paused() + + async def resume(self): + await self._bridge.resume() + + async def hold_position(self): + await self._bridge.hold_position() + + def foot_on_ground(self, foot="left"): + return self._bridge.foot_on_ground(foot) + + def get_distance_sensor(self): + return self._bridge.get_distance_sensor() + + def get_accelerometer(self): + result = self._bridge.get_accelerometer() + return {"x": result.x, "y": result.y, "z": result.z} + + async def play_sound(self, name): + await self._bridge.play_sound(name) + +_martypy_mod.Marty = Marty +sys.modules["martypy"] = _martypy_mod +`; + +export const EXECUTION_WRAPPER_CODE = ` +import asyncio +import sys +from martypy import Marty + +async def __run_user_code(): +{user_code} + +asyncio.ensure_future(__run_user_code()) +`; + +export function wrapUserCode(code: string): string { + const indented = code + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + + return EXECUTION_WRAPPER_CODE.replace("{user_code}", indented); +} diff --git a/src/features/python-runtime/pyodide-service.ts b/src/features/python-runtime/pyodide-service.ts new file mode 100644 index 0000000..507b71f --- /dev/null +++ b/src/features/python-runtime/pyodide-service.ts @@ -0,0 +1,111 @@ +import type { PyodideLoadingState } from "./types"; + +const PYODIDE_VERSION = "0.27.5"; +const PYODIDE_CDN_BASE = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full`; +const PYODIDE_CDN_URL = `${PYODIDE_CDN_BASE}/pyodide.js`; + +export interface PyodideInstance { + readonly runPythonAsync: (code: string) => Promise; + readonly registerJsModule: (name: string, module: object) => void; + readonly setStdout: (options: { batched: (text: string) => void }) => void; + readonly setStderr: (options: { batched: (text: string) => void }) => void; + readonly globals: { get: (name: string) => unknown }; +} + +type StateListener = (state: PyodideLoadingState, error?: string) => void; + +let pyodideInstance: PyodideInstance | null = null; +let loadPromise: Promise | null = null; +const stateListeners: StateListener[] = []; + +function notifyListeners(state: PyodideLoadingState, error?: string): void { + for (const listener of stateListeners) { + listener(state, error); + } +} + +export function onStateChange(listener: StateListener): () => void { + stateListeners.push(listener); + return () => { + const index = stateListeners.indexOf(listener); + if (index >= 0) { + stateListeners.splice(index, 1); + } + }; +} + +export function getLoadingState(): PyodideLoadingState { + if (pyodideInstance) return "ready"; + if (loadPromise) return "loading"; + return "idle"; +} + +export function getInstance(): PyodideInstance | null { + return pyodideInstance; +} + +async function loadPyodideScript(): Promise { + if (typeof window === "undefined") { + throw new Error("Pyodide can only be loaded in a browser environment"); + } + + const existingScript = document.querySelector( + `script[src="${PYODIDE_CDN_URL}"]`, + ); + if (existingScript) return; + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = PYODIDE_CDN_URL; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load Pyodide script")); + document.head.appendChild(script); + }); +} + +export async function loadPyodide(): Promise { + if (pyodideInstance) return pyodideInstance; + + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + try { + notifyListeners("loading"); + + await loadPyodideScript(); + + const globalWindow = window as unknown as { + loadPyodide?: (options: { + indexURL: string; + }) => Promise; + }; + + if (!globalWindow.loadPyodide) { + throw new Error("loadPyodide function not found on window"); + } + + const instance = await globalWindow.loadPyodide({ + indexURL: `${PYODIDE_CDN_BASE}/`, + }); + + pyodideInstance = instance; + notifyListeners("ready"); + return instance; + } catch (err) { + const message = + err instanceof Error ? err.message : "Unknown error loading Pyodide"; + loadPromise = null; + notifyListeners("error", message); + throw new Error(`Pyodide initialization failed: ${message}`); + } + })(); + + return loadPromise; +} + +export function resetForTesting(): void { + pyodideInstance = null; + loadPromise = null; + stateListeners.length = 0; +} diff --git a/src/features/python-runtime/python-executor.ts b/src/features/python-runtime/python-executor.ts new file mode 100644 index 0000000..97a6b1d --- /dev/null +++ b/src/features/python-runtime/python-executor.ts @@ -0,0 +1,94 @@ +import type { PyodideInstance } from "./pyodide-service"; +import type { ConsoleEntry, PythonExecutionResult } from "./types"; +import { VirtualMarty } from "@/features/marty/virtual-marty"; +import { + createMartyBridge, + MARTYPY_MODULE_CODE, + wrapUserCode, +} from "./martypy-module"; + +let entryCounter = 0; + +function createEntry(type: ConsoleEntry["type"], text: string): ConsoleEntry { + entryCounter += 1; + return { + id: `entry-${entryCounter}`, + type, + text, + timestamp: Date.now(), + }; +} + +export function formatPythonError(error: string): string { + const lines = error.split("\n"); + const formatted: string[] = []; + + for (const line of lines) { + const lineMatch = line.match(/line (\d+)/); + if (lineMatch) { + const adjustedLine = Math.max(1, parseInt(lineMatch[1], 10) - 5); + formatted.push(line.replace(/line \d+/, `line ${adjustedLine}`)); + } else { + formatted.push(line); + } + } + + return formatted.join("\n"); +} + +export interface ExecutorCallbacks { + readonly onStdout: (entry: ConsoleEntry) => void; + readonly onStderr: (entry: ConsoleEntry) => void; +} + +export async function registerMartyModule( + pyodide: PyodideInstance, + marty: VirtualMarty, +): Promise { + const bridge = createMartyBridge(marty); + const jsModule = { _marty_bridge: bridge }; + pyodide.registerJsModule("pyodide_js", jsModule); + + await pyodide.runPythonAsync(MARTYPY_MODULE_CODE); +} + +export async function executePythonCode( + pyodide: PyodideInstance, + code: string, + callbacks: ExecutorCallbacks, +): Promise { + pyodide.setStdout({ + batched: (text: string) => { + callbacks.onStdout(createEntry("stdout", text)); + }, + }); + + pyodide.setStderr({ + batched: (text: string) => { + callbacks.onStderr(createEntry("stderr", text)); + }, + }); + + try { + const strippedCode = code + .split("\n") + .filter((line) => !line.match(/^\s*from\s+martypy\s+import/)) + .filter((line) => !line.match(/^\s*import\s+martypy/)) + .join("\n"); + + const wrappedCode = wrapUserCode(strippedCode); + await pyodide.runPythonAsync(wrappedCode); + + return { success: true, error: null }; + } catch (err) { + const rawMessage = err instanceof Error ? err.message : String(err); + const formattedError = formatPythonError(rawMessage); + callbacks.onStderr(createEntry("stderr", formattedError)); + + return { success: false, error: formattedError }; + } +} + +export function resetEntryCounter(): void { + entryCounter = 0; +} diff --git a/src/features/python-runtime/types.ts b/src/features/python-runtime/types.ts new file mode 100644 index 0000000..9fed13a --- /dev/null +++ b/src/features/python-runtime/types.ts @@ -0,0 +1,24 @@ +export type PyodideLoadingState = "idle" | "loading" | "ready" | "error"; + +export interface PyodideStatus { + readonly state: PyodideLoadingState; + readonly error: string | null; +} + +export interface ConsoleEntry { + readonly id: string; + readonly type: "stdout" | "stderr" | "info"; + readonly text: string; + readonly timestamp: number; +} + +export interface PythonExecutionResult { + readonly success: boolean; + readonly error: string | null; +} + +export interface ExecutionState { + readonly isRunning: boolean; + readonly consoleEntries: readonly ConsoleEntry[]; + readonly lastError: string | null; +} diff --git a/src/features/scene/animation/definitions.ts b/src/features/scene/animation/definitions.ts new file mode 100644 index 0000000..020ab67 --- /dev/null +++ b/src/features/scene/animation/definitions.ts @@ -0,0 +1,459 @@ +import type { AnimationSequence } from "./types"; +import type { MartyPose, JointAngles, EyeState } from "../types"; +import { DEFAULT_POSE } from "../types"; + +interface PoseOverrides { + readonly joints?: Partial; + readonly eyes?: { + readonly left?: Partial; + readonly right?: Partial; + }; + readonly bodyY?: number; + readonly bodyTilt?: number; +} + +function pose(overrides: PoseOverrides): MartyPose { + return { + ...DEFAULT_POSE, + ...overrides, + joints: { ...DEFAULT_POSE.joints, ...overrides.joints }, + eyes: { + left: { ...DEFAULT_POSE.eyes.left, ...overrides.eyes?.left }, + right: { ...DEFAULT_POSE.eyes.right, ...overrides.eyes?.right }, + }, + }; +} + +export const WALK_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 1000, + keyframes: [ + { + time: 0, + pose: pose({ + joints: { + leftHip: 0.3, + rightHip: -0.3, + leftArm: -0.2, + rightArm: 0.2, + leftKnee: -0.1, + rightKnee: 0.1, + }, + }), + }, + { + time: 0.25, + pose: pose({ + joints: { leftHip: 0, rightHip: 0, leftArm: 0, rightArm: 0 }, + bodyY: 0.02, + }), + }, + { + time: 0.5, + pose: pose({ + joints: { + leftHip: -0.3, + rightHip: 0.3, + leftArm: 0.2, + rightArm: -0.2, + leftKnee: 0.1, + rightKnee: -0.1, + }, + }), + }, + { + time: 0.75, + pose: pose({ + joints: { leftHip: 0, rightHip: 0, leftArm: 0, rightArm: 0 }, + bodyY: 0.02, + }), + }, + ], +}; + +export const DANCE_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 2000, + keyframes: [ + { + time: 0, + pose: pose({ + joints: { leftArm: 0.8, rightArm: -0.8, twist: 0.2 }, + bodyY: -0.05, + }), + }, + { + time: 0.25, + pose: pose({ + joints: { leftArm: -0.5, rightArm: 0.5, twist: -0.2 }, + bodyY: 0.05, + }), + }, + { + time: 0.5, + pose: pose({ + joints: { leftArm: 0.8, rightArm: -0.8, twist: 0.3 }, + bodyY: -0.05, + }), + }, + { + time: 0.75, + pose: pose({ + joints: { leftArm: -0.5, rightArm: 0.5, twist: -0.3 }, + bodyY: 0.05, + }), + }, + ], +}; + +export const KICK_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 1000, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { + time: 0.3, + pose: pose({ + joints: { rightHip: -0.3, rightKnee: 0.4 }, + bodyTilt: -0.05, + }), + }, + { + time: 0.5, + pose: pose({ + joints: { rightHip: 0.8, rightKnee: -0.2, rightFoot: -0.3 }, + bodyTilt: -0.1, + }), + }, + { + time: 0.8, + pose: pose({ joints: { rightHip: 0.3, rightKnee: 0 }, bodyTilt: -0.05 }), + }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const SLIDE_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 1000, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { + time: 0.3, + pose: pose({ bodyTilt: 0.15, joints: { leftHip: 0.1, rightHip: -0.1 } }), + }, + { + time: 0.7, + pose: pose({ bodyTilt: 0.15, joints: { leftHip: 0.1, rightHip: -0.1 } }), + }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const LEAN_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 500, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { time: 0.4, pose: pose({ bodyTilt: 0.25 }) }, + { time: 0.7, pose: pose({ bodyTilt: 0.25 }) }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const WIGGLE_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 500, + keyframes: [ + { time: 0, pose: pose({ joints: { twist: 0.15 } }) }, + { time: 0.25, pose: pose({ joints: { twist: -0.15 } }) }, + { time: 0.5, pose: pose({ joints: { twist: 0.15 } }) }, + { time: 0.75, pose: pose({ joints: { twist: -0.15 } }) }, + ], +}; + +export const CIRCLE_DANCE_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 3000, + keyframes: [ + { + time: 0, + pose: pose({ + joints: { twist: 0, leftArm: 0.6, rightArm: 0.6 }, + bodyY: 0.03, + }), + }, + { + time: 0.25, + pose: pose({ + joints: { twist: Math.PI / 2, leftArm: 0.3, rightArm: 0.3 }, + bodyY: -0.02, + }), + }, + { + time: 0.5, + pose: pose({ + joints: { twist: Math.PI, leftArm: 0.6, rightArm: 0.6 }, + bodyY: 0.03, + }), + }, + { + time: 0.75, + pose: pose({ + joints: { twist: Math.PI * 1.5, leftArm: 0.3, rightArm: 0.3 }, + bodyY: -0.02, + }), + }, + ], +}; + +export const CELEBRATE_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 2000, + keyframes: [ + { + time: 0, + pose: pose({ + joints: { leftArm: -1.2, rightArm: -1.2, twist: 0.1 }, + bodyY: 0.05, + }), + }, + { + time: 0.2, + pose: pose({ + joints: { leftArm: -1.0, rightArm: -1.0, twist: -0.1 }, + bodyY: -0.02, + }), + }, + { + time: 0.4, + pose: pose({ + joints: { leftArm: -1.2, rightArm: -1.2, twist: 0.1 }, + bodyY: 0.05, + }), + }, + { + time: 0.6, + pose: pose({ + joints: { leftArm: -1.0, rightArm: -1.0, twist: -0.1 }, + bodyY: -0.02, + }), + }, + { + time: 0.8, + pose: pose({ + joints: { leftArm: -1.2, rightArm: -1.2, twist: 0 }, + bodyY: 0.05, + }), + }, + { + time: 1, + pose: pose({ joints: { leftArm: -1.2, rightArm: -1.2 }, bodyY: 0 }), + }, + ], +}; + +export const GET_READY_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 500, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { + time: 0.5, + pose: pose({ joints: { leftKnee: 0.1, rightKnee: 0.1 }, bodyY: -0.02 }), + }, + { + time: 1, + pose: pose({ joints: { leftKnee: 0.1, rightKnee: 0.1 }, bodyY: -0.02 }), + }, + ], +}; + +export const STAND_STRAIGHT_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 500, + keyframes: [ + { + time: 0, + pose: pose({ joints: { leftKnee: 0.1, rightKnee: 0.1 }, bodyY: -0.02 }), + }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const EYES_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 300, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const ARMS_SEQUENCE: AnimationSequence = { + loop: false, + durationMs: 500, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export const IDLE_SEQUENCE: AnimationSequence = { + loop: true, + durationMs: 3000, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { time: 0.5, pose: pose({ bodyY: 0.01 }) }, + { time: 1, pose: DEFAULT_POSE }, + ], +}; + +export function getSequenceForAction( + action: string, + params: Readonly> = {}, +): AnimationSequence { + switch (action) { + case "walk": + return WALK_SEQUENCE; + case "dance": + return DANCE_SEQUENCE; + case "kick": + return getKickSequence(params); + case "slide": + return getSlideSequence(params); + case "lean": + return getLeanSequence(params); + case "wiggle": + return WIGGLE_SEQUENCE; + case "circle_dance": + return CIRCLE_DANCE_SEQUENCE; + case "celebrate": + return CELEBRATE_SEQUENCE; + case "get_ready": + return GET_READY_SEQUENCE; + case "stand_straight": + return STAND_STRAIGHT_SEQUENCE; + case "eyes": + return getEyesSequence(params); + case "arms": + return getArmsSequence(params); + default: + return IDLE_SEQUENCE; + } +} + +function getKickSequence( + params: Readonly>, +): AnimationSequence { + const leg = params.leg === "left" ? "left" : "right"; + if (leg === "left") { + return { + ...KICK_SEQUENCE, + keyframes: KICK_SEQUENCE.keyframes.map((kf) => ({ + ...kf, + pose: { + ...kf.pose, + joints: { + ...kf.pose.joints, + leftHip: kf.pose.joints.rightHip, + leftKnee: kf.pose.joints.rightKnee, + leftFoot: kf.pose.joints.rightFoot, + rightHip: 0, + rightKnee: 0, + rightFoot: 0, + }, + bodyTilt: -kf.pose.bodyTilt, + }, + })), + }; + } + return KICK_SEQUENCE; +} + +function getSlideSequence( + params: Readonly>, +): AnimationSequence { + const direction = params.direction === "right" ? -1 : 1; + return { + ...SLIDE_SEQUENCE, + keyframes: SLIDE_SEQUENCE.keyframes.map((kf) => ({ + ...kf, + pose: { + ...kf.pose, + bodyTilt: kf.pose.bodyTilt * direction, + }, + })), + }; +} + +function getLeanSequence( + params: Readonly>, +): AnimationSequence { + const direction = params.direction === "right" ? -1 : 1; + const amount = typeof params.amount === "number" ? params.amount / 30 : 1; + return { + ...LEAN_SEQUENCE, + keyframes: LEAN_SEQUENCE.keyframes.map((kf) => ({ + ...kf, + pose: { + ...kf.pose, + bodyTilt: kf.pose.bodyTilt * direction * amount, + }, + })), + }; +} + +function getEyesSequence( + params: Readonly>, +): AnimationSequence { + const position = (params.position as string) ?? "normal"; + const eyeConfig = getEyeConfig(position); + return { + ...EYES_SEQUENCE, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { + time: 1, + pose: pose({ + eyes: { left: eyeConfig, right: eyeConfig }, + }), + }, + ], + }; +} + +function getEyeConfig(position: string): { + x: number; + y: number; + scale: number; +} { + switch (position) { + case "wide": + return { x: 0, y: 0, scale: 1.3 }; + case "angry": + return { x: 0, y: -0.02, scale: 0.7 }; + case "excited": + return { x: 0, y: 0.02, scale: 1.2 }; + case "squint": + return { x: 0, y: 0, scale: 0.5 }; + default: + return { x: 0, y: 0, scale: 1 }; + } +} + +function getArmsSequence( + params: Readonly>, +): AnimationSequence { + const left = + typeof params.left === "number" ? (params.left * Math.PI) / 180 : 0; + const right = + typeof params.right === "number" ? (params.right * Math.PI) / 180 : 0; + return { + ...ARMS_SEQUENCE, + keyframes: [ + { time: 0, pose: DEFAULT_POSE }, + { time: 1, pose: pose({ joints: { leftArm: left, rightArm: right } }) }, + ], + }; +} diff --git a/src/features/scene/animation/index.ts b/src/features/scene/animation/index.ts new file mode 100644 index 0000000..2b74030 --- /dev/null +++ b/src/features/scene/animation/index.ts @@ -0,0 +1,10 @@ +export { AnimationPlayer } from "./player"; +export { getSequenceForAction } from "./definitions"; +export { useMartyAnimation } from "./useMartyAnimation"; +export type { + AnimationKeyframe, + AnimationSequence, + AnimationAction, + AnimationState, + AnimationPlayerState, +} from "./types"; diff --git a/src/features/scene/animation/player.ts b/src/features/scene/animation/player.ts new file mode 100644 index 0000000..cdfa2a0 --- /dev/null +++ b/src/features/scene/animation/player.ts @@ -0,0 +1,157 @@ +import type { AnimationSequence, AnimationAction } from "./types"; +import type { MartyPose, JointAngles, EyeState } from "../types"; +import { DEFAULT_POSE } from "../types"; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function lerpJoints(a: JointAngles, b: JointAngles, t: number): JointAngles { + return { + leftHip: lerp(a.leftHip, b.leftHip, t), + leftKnee: lerp(a.leftKnee, b.leftKnee, t), + leftFoot: lerp(a.leftFoot, b.leftFoot, t), + rightHip: lerp(a.rightHip, b.rightHip, t), + rightKnee: lerp(a.rightKnee, b.rightKnee, t), + rightFoot: lerp(a.rightFoot, b.rightFoot, t), + leftArm: lerp(a.leftArm, b.leftArm, t), + rightArm: lerp(a.rightArm, b.rightArm, t), + twist: lerp(a.twist, b.twist, t), + }; +} + +function lerpEyes(a: EyeState, b: EyeState, t: number): EyeState { + return { + left: { + x: lerp(a.left.x, b.left.x, t), + y: lerp(a.left.y, b.left.y, t), + scale: lerp(a.left.scale, b.left.scale, t), + }, + right: { + x: lerp(a.right.x, b.right.x, t), + y: lerp(a.right.y, b.right.y, t), + scale: lerp(a.right.scale, b.right.scale, t), + }, + }; +} + +function lerpPose(a: MartyPose, b: MartyPose, t: number): MartyPose { + return { + joints: lerpJoints(a.joints, b.joints, t), + eyes: lerpEyes(a.eyes, b.eyes, t), + bodyY: lerp(a.bodyY, b.bodyY, t), + bodyTilt: lerp(a.bodyTilt, b.bodyTilt, t), + }; +} + +function interpolateSequence( + sequence: AnimationSequence, + progress: number, +): MartyPose { + const { keyframes } = sequence; + + if (keyframes.length === 0) { + return DEFAULT_POSE; + } + + if (keyframes.length === 1) { + return keyframes[0].pose; + } + + const clampedProgress = Math.max(0, Math.min(1, progress)); + + let beforeIndex = 0; + for (let i = 0; i < keyframes.length - 1; i++) { + if (keyframes[i].time <= clampedProgress) { + beforeIndex = i; + } + } + + const afterIndex = Math.min(beforeIndex + 1, keyframes.length - 1); + const before = keyframes[beforeIndex]; + const after = keyframes[afterIndex]; + + if (beforeIndex === afterIndex) { + return before.pose; + } + + const segmentDuration = after.time - before.time; + const segmentProgress = + segmentDuration > 0 ? (clampedProgress - before.time) / segmentDuration : 0; + + return lerpPose(before.pose, after.pose, segmentProgress); +} + +export class AnimationPlayer { + private sequence: AnimationSequence | null = null; + private action: AnimationAction = "idle"; + private elapsedMs = 0; + private playing = false; + private currentPose: MartyPose = DEFAULT_POSE; + private onCompleteCallback: (() => void) | null = null; + + play( + action: AnimationAction, + sequence: AnimationSequence, + onComplete?: () => void, + ): void { + this.sequence = sequence; + this.action = action; + this.elapsedMs = 0; + this.playing = true; + this.onCompleteCallback = onComplete ?? null; + } + + stop(): void { + this.playing = false; + this.sequence = null; + this.elapsedMs = 0; + this.currentPose = DEFAULT_POSE; + this.onCompleteCallback = null; + } + + tick(deltaMs: number): MartyPose { + if (!this.playing || !this.sequence) { + return this.currentPose; + } + + this.elapsedMs += deltaMs; + const { durationMs, loop } = this.sequence; + + if (durationMs <= 0) { + this.currentPose = DEFAULT_POSE; + return this.currentPose; + } + + let progress = this.elapsedMs / durationMs; + + if (loop) { + progress = progress % 1; + } else if (progress >= 1) { + progress = 1; + this.currentPose = interpolateSequence(this.sequence, 1); + this.playing = false; + const cb = this.onCompleteCallback; + this.onCompleteCallback = null; + cb?.(); + return this.currentPose; + } + + this.currentPose = interpolateSequence(this.sequence, progress); + return this.currentPose; + } + + isPlaying(): boolean { + return this.playing; + } + + getCurrentAction(): AnimationAction { + return this.action; + } + + getCurrentPose(): MartyPose { + return this.currentPose; + } +} + +export { lerp, lerpJoints, lerpEyes, lerpPose, interpolateSequence }; diff --git a/src/features/scene/animation/types.ts b/src/features/scene/animation/types.ts new file mode 100644 index 0000000..60b3605 --- /dev/null +++ b/src/features/scene/animation/types.ts @@ -0,0 +1,40 @@ +import type { MartyPose } from "../types"; + +export interface AnimationKeyframe { + readonly time: number; // 0..1 normalized progress + readonly pose: MartyPose; +} + +export interface AnimationSequence { + readonly keyframes: readonly AnimationKeyframe[]; + readonly loop: boolean; + readonly durationMs: number; +} + +export type AnimationAction = + | "walk" + | "dance" + | "kick" + | "slide" + | "lean" + | "wiggle" + | "circle_dance" + | "celebrate" + | "get_ready" + | "stand_straight" + | "eyes" + | "arms" + | "idle"; + +export interface AnimationState { + readonly action: AnimationAction; + readonly sequence: AnimationSequence; + readonly startTime: number; + readonly params: Readonly>; +} + +export interface AnimationPlayerState { + readonly currentAnimation: AnimationState | null; + readonly currentPose: MartyPose; + readonly isPlaying: boolean; +} diff --git a/src/features/scene/animation/useMartyAnimation.ts b/src/features/scene/animation/useMartyAnimation.ts new file mode 100644 index 0000000..15c7cbd --- /dev/null +++ b/src/features/scene/animation/useMartyAnimation.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useRef, useEffect, useCallback, useState } from "react"; +import { useFrame } from "@react-three/fiber"; +import type { VirtualMarty } from "@/features/marty/virtual-marty"; +import type { CommandStartEvent } from "@/features/marty/types"; +import type { MartyModelHandle } from "../components/MartyModel"; +import type { AnimationAction } from "./types"; +import { AnimationPlayer } from "./player"; +import { getSequenceForAction } from "./definitions"; + +interface UseMartyAnimationOptions { + readonly marty: VirtualMarty | null; +} + +interface UseMartyAnimationResult { + readonly currentAction: AnimationAction; + readonly isAnimating: boolean; + readonly onModelReady: (handle: MartyModelHandle) => void; +} + +export function useMartyAnimation({ + marty, +}: UseMartyAnimationOptions): UseMartyAnimationResult { + const playerRef = useRef(new AnimationPlayer()); + const modelRef = useRef(null); + const [currentAction, setCurrentAction] = useState("idle"); + const [isAnimating, setIsAnimating] = useState(false); + + const onModelReady = useCallback((handle: MartyModelHandle) => { + modelRef.current = handle; + }, []); + + useEffect(() => { + if (!marty) { + return; + } + + const player = playerRef.current; + + const handleCommandStart = (event: CommandStartEvent) => { + const { command } = event; + const action = command.action as AnimationAction; + const sequence = getSequenceForAction(command.action, command.params); + + player.play(action, sequence); + setCurrentAction(action); + setIsAnimating(true); + }; + + const handleCommandComplete = () => { + player.stop(); + setCurrentAction("idle"); + setIsAnimating(false); + }; + + marty.on("commandStart", handleCommandStart); + marty.on("commandComplete", handleCommandComplete); + + return () => { + marty.off("commandStart", handleCommandStart); + marty.off("commandComplete", handleCommandComplete); + player.stop(); + }; + }, [marty]); + + // Drive animation per frame — this runs at ~60fps inside the R3F Canvas + useFrame((_state, delta) => { + if (!playerRef.current.isPlaying()) { + return; + } + + const deltaMs = delta * 1000; + const pose = playerRef.current.tick(deltaMs); + + if (modelRef.current) { + modelRef.current.applyPose(pose); + } + + if (!playerRef.current.isPlaying()) { + setIsAnimating(false); + setCurrentAction("idle"); + } + }); + + return { + currentAction, + isAnimating, + onModelReady, + }; +} diff --git a/src/features/scene/components/AnimatedMarty.tsx b/src/features/scene/components/AnimatedMarty.tsx new file mode 100644 index 0000000..cc06ac9 --- /dev/null +++ b/src/features/scene/components/AnimatedMarty.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useCallback } from "react"; +import type { VirtualMarty } from "@/features/marty/virtual-marty"; +import { MartyModel } from "./MartyModel"; +import type { MartyModelHandle } from "./MartyModel"; +import { useMartyAnimation } from "../animation/useMartyAnimation"; + +interface AnimatedMartyProps { + readonly marty: VirtualMarty | null; +} + +export function AnimatedMarty({ marty }: AnimatedMartyProps) { + const { onModelReady } = useMartyAnimation({ marty }); + + const handleRef = useCallback( + (handle: MartyModelHandle | null) => { + if (handle) { + onModelReady(handle); + } + }, + [onModelReady], + ); + + return ; +} diff --git a/src/features/scene/components/MartyModel.tsx b/src/features/scene/components/MartyModel.tsx new file mode 100644 index 0000000..bcb1cd0 --- /dev/null +++ b/src/features/scene/components/MartyModel.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useRef, forwardRef, useImperativeHandle } from "react"; +import type { Group } from "three"; +import type { MartyPose } from "../types"; +import { DEFAULT_POSE } from "../types"; + +// --- Dimension constants --- +const BODY_WIDTH = 0.6; +const BODY_HEIGHT = 0.5; +const BODY_DEPTH = 0.4; + +const HEAD_SIZE = 0.45; +const EYE_RADIUS = 0.08; +const PUPIL_RADIUS = 0.04; + +const LEG_UPPER_HEIGHT = 0.4; +const LEG_LOWER_HEIGHT = 0.35; +const LEG_WIDTH = 0.12; +const FOOT_LENGTH = 0.25; +const FOOT_HEIGHT = 0.08; +const LEG_SPACING = 0.18; + +const ARM_LENGTH = 0.45; +const ARM_WIDTH = 0.08; +const ARM_SPACING = BODY_WIDTH / 2 + ARM_WIDTH / 2; + +// --- Colors --- +const BODY_COLOR = "#f5f5f5"; +const ACCENT_COLOR = "#4a90d9"; +const EYE_WHITE = "#ffffff"; +const PUPIL_COLOR = "#333333"; +const FOOT_COLOR = "#666666"; + +export interface MartyModelHandle { + readonly applyPose: (pose: MartyPose) => void; + readonly getRootRef: () => Group | null; +} + +interface MartyModelProps { + readonly initialPose?: MartyPose; +} + +function LegGeometry({ side }: { readonly side: "left" | "right" }) { + const isLeft = side === "left"; + return ( + <> + {/* Upper leg */} + + + + + + {/* Lower leg offset — knee pivot sits at bottom of upper leg */} + {/* Note: knee and foot groups are managed by parent via refs */} + + + + + + ); +} + +function FootGeometry() { + return ( + + + + + ); +} + +function ArmGeometry() { + return ( + <> + + + + + {/* Hand */} + + + + + + ); +} + +function EyeGeometry() { + return ( + <> + + + + + + + + + + ); +} + +export const MartyModel = forwardRef( + function MartyModel({ initialPose = DEFAULT_POSE }, ref) { + const rootRef = useRef(null); + + const leftHipRef = useRef(null); + const leftKneeRef = useRef(null); + const leftFootRef = useRef(null); + const rightHipRef = useRef(null); + const rightKneeRef = useRef(null); + const rightFootRef = useRef(null); + const leftArmRef = useRef(null); + const rightArmRef = useRef(null); + const leftEyeRef = useRef(null); + const rightEyeRef = useRef(null); + + useImperativeHandle(ref, () => ({ + applyPose: (pose: MartyPose) => { + const { joints, eyes } = pose; + + leftHipRef.current?.rotation?.set(joints.leftHip, 0, 0); + leftKneeRef.current?.rotation?.set(joints.leftKnee, 0, 0); + leftFootRef.current?.rotation?.set(joints.leftFoot, 0, 0); + rightHipRef.current?.rotation?.set(joints.rightHip, 0, 0); + rightKneeRef.current?.rotation?.set(joints.rightKnee, 0, 0); + rightFootRef.current?.rotation?.set(joints.rightFoot, 0, 0); + leftArmRef.current?.rotation?.set(joints.leftArm, 0, 0); + rightArmRef.current?.rotation?.set(joints.rightArm, 0, 0); + + if (rootRef.current?.rotation) { + rootRef.current.rotation.y = joints.twist; + rootRef.current.rotation.z = pose.bodyTilt; + } + if (rootRef.current?.position) { + rootRef.current.position.y = pose.bodyY; + } + + if (leftEyeRef.current?.position) { + leftEyeRef.current.position.x = -0.1 + eyes.left.x; + leftEyeRef.current.position.y = HEAD_SIZE * 0.1 + eyes.left.y; + leftEyeRef.current.scale?.setScalar(eyes.left.scale); + } + + if (rightEyeRef.current?.position) { + rightEyeRef.current.position.x = 0.1 + eyes.right.x; + rightEyeRef.current.position.y = HEAD_SIZE * 0.1 + eyes.right.y; + rightEyeRef.current.scale?.setScalar(eyes.right.scale); + } + }, + getRootRef: () => rootRef.current, + })); + + void initialPose; + + return ( + + {/* Body */} + + + + + + {/* Head */} + + + + + + + {/* Eyes */} + + + + + + + + + {/* Left leg */} + + + + + + + + + + {/* Right leg */} + + + + + + + + + + {/* Left arm */} + + + + + {/* Right arm */} + + + + + ); + }, +); diff --git a/src/features/scene/components/MartyScene.tsx b/src/features/scene/components/MartyScene.tsx new file mode 100644 index 0000000..885dcc9 --- /dev/null +++ b/src/features/scene/components/MartyScene.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useRef, useCallback } from "react"; +import { Canvas } from "@react-three/fiber"; +import type { VirtualMarty } from "@/features/marty/virtual-marty"; +import { MartyModel } from "./MartyModel"; +import type { MartyModelHandle } from "./MartyModel"; +import { AnimatedMarty } from "./AnimatedMarty"; +import { SceneEnvironment } from "./SceneEnvironment"; +import type { MartyPose, SceneConfig } from "../types"; +import { DEFAULT_SCENE_CONFIG } from "../types"; + +interface MartySceneProps { + readonly config?: Partial; + readonly marty?: VirtualMarty | null; + readonly onModelReady?: (handle: MartyModelHandle) => void; +} + +export function MartyScene({ + config: configOverrides, + marty = null, + onModelReady, +}: MartySceneProps) { + const modelRef = useRef(null); + + const config: SceneConfig = { + ...DEFAULT_SCENE_CONFIG, + ...configOverrides, + }; + + const handleRef = useCallback( + (handle: MartyModelHandle | null) => { + if (handle) { + (modelRef as React.MutableRefObject).current = + handle; + onModelReady?.(handle); + } + }, + [onModelReady], + ); + + return ( +
+ + + {marty ? ( + + ) : ( + + )} + +
+ ); +} + +export type { MartyModelHandle, MartyPose }; diff --git a/src/features/scene/components/SceneEnvironment.tsx b/src/features/scene/components/SceneEnvironment.tsx new file mode 100644 index 0000000..0f2422a --- /dev/null +++ b/src/features/scene/components/SceneEnvironment.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Grid, OrbitControls } from "@react-three/drei"; + +interface SceneEnvironmentProps { + readonly showGrid?: boolean; +} + +export function SceneEnvironment({ showGrid = true }: SceneEnvironmentProps) { + return ( + <> + {/* Lighting */} + + + + + {/* Camera controls */} + + + {/* Ground grid */} + {showGrid && ( + + )} + + ); +} diff --git a/src/features/scene/hooks/useMartyModel.ts b/src/features/scene/hooks/useMartyModel.ts new file mode 100644 index 0000000..3d454ca --- /dev/null +++ b/src/features/scene/hooks/useMartyModel.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import type { VirtualMarty } from "@/features/marty/virtual-marty"; +import type { CommandStartEvent } from "@/features/marty/types"; +import type { MartyPose } from "../types"; +import { DEFAULT_POSE } from "../types"; +import type { MartyModelHandle } from "../components/MartyModel"; + +interface UseMartyModelOptions { + readonly marty: VirtualMarty | null; +} + +interface UseMartyModelResult { + readonly pose: MartyPose; + readonly isConnected: boolean; + readonly currentAction: string | null; + readonly onModelReady: (handle: MartyModelHandle) => void; +} + +export function useMartyModel({ + marty, +}: UseMartyModelOptions): UseMartyModelResult { + const [pose, setPose] = useState(DEFAULT_POSE); + const [currentAction, setCurrentAction] = useState(null); + const modelHandleRef = useRef(null); + + const isConnected = useMemo(() => marty !== null, [marty]); + + const onModelReady = useCallback((handle: MartyModelHandle) => { + modelHandleRef.current = handle; + }, []); + + useEffect(() => { + if (!marty) { + return; + } + + const handleCommandStart = (event: CommandStartEvent) => { + setCurrentAction(event.command.action); + }; + + const handleCommandComplete = () => { + setCurrentAction(null); + setPose(DEFAULT_POSE); + if (modelHandleRef.current) { + modelHandleRef.current.applyPose(DEFAULT_POSE); + } + }; + + marty.on("commandStart", handleCommandStart); + marty.on("commandComplete", handleCommandComplete); + + return () => { + marty.off("commandStart", handleCommandStart); + marty.off("commandComplete", handleCommandComplete); + }; + }, [marty]); + + useEffect(() => { + if (modelHandleRef.current) { + modelHandleRef.current.applyPose(pose); + } + }, [pose]); + + return { + pose, + isConnected, + currentAction, + onModelReady, + }; +} diff --git a/src/features/scene/index.ts b/src/features/scene/index.ts new file mode 100644 index 0000000..98b059a --- /dev/null +++ b/src/features/scene/index.ts @@ -0,0 +1,15 @@ +export { MartyScene } from "./components/MartyScene"; +export type { MartyModelHandle, MartyPose } from "./components/MartyScene"; +export { MartyModel } from "./components/MartyModel"; +export type { MartyModelHandle as MartyModelRef } from "./components/MartyModel"; +export { SceneEnvironment } from "./components/SceneEnvironment"; +export { + DEFAULT_POSE, + DEFAULT_SCENE_CONFIG, + DEFAULT_JOINT_ANGLES, + DEFAULT_EYE_STATE, +} from "./types"; +export type { JointAngles, EyeState, SceneConfig, JointName } from "./types"; +export { AnimatedMarty } from "./components/AnimatedMarty"; +export { AnimationPlayer, getSequenceForAction } from "./animation"; +export type { AnimationAction, AnimationSequence } from "./animation"; diff --git a/src/features/scene/types.ts b/src/features/scene/types.ts new file mode 100644 index 0000000..0d5ea32 --- /dev/null +++ b/src/features/scene/types.ts @@ -0,0 +1,71 @@ +export interface JointAngles { + readonly leftHip: number; + readonly leftKnee: number; + readonly leftFoot: number; + readonly rightHip: number; + readonly rightKnee: number; + readonly rightFoot: number; + readonly leftArm: number; + readonly rightArm: number; + readonly twist: number; +} + +export interface EyeState { + readonly left: { + readonly x: number; + readonly y: number; + readonly scale: number; + }; + readonly right: { + readonly x: number; + readonly y: number; + readonly scale: number; + }; +} + +export interface MartyPose { + readonly joints: JointAngles; + readonly eyes: EyeState; + readonly bodyY: number; + readonly bodyTilt: number; +} + +export const DEFAULT_JOINT_ANGLES: JointAngles = { + leftHip: 0, + leftKnee: 0, + leftFoot: 0, + rightHip: 0, + rightKnee: 0, + rightFoot: 0, + leftArm: 0, + rightArm: 0, + twist: 0, +}; + +export const DEFAULT_EYE_STATE: EyeState = { + left: { x: 0, y: 0, scale: 1 }, + right: { x: 0, y: 0, scale: 1 }, +}; + +export const DEFAULT_POSE: MartyPose = { + joints: DEFAULT_JOINT_ANGLES, + eyes: DEFAULT_EYE_STATE, + bodyY: 0, + bodyTilt: 0, +}; + +export type JointName = keyof JointAngles; + +export interface SceneConfig { + readonly backgroundColor: string; + readonly showGrid: boolean; + readonly showAxes: boolean; + readonly cameraPosition: readonly [number, number, number]; +} + +export const DEFAULT_SCENE_CONFIG: SceneConfig = { + backgroundColor: "#f0f0f0", + showGrid: true, + showAxes: false, + cameraPosition: [3, 3, 5], +}; diff --git a/src/features/tutorials/tutorial-data.ts b/src/features/tutorials/tutorial-data.ts new file mode 100644 index 0000000..6316710 --- /dev/null +++ b/src/features/tutorials/tutorial-data.ts @@ -0,0 +1,356 @@ +export interface TutorialStep { + readonly title: string; + readonly description: string; + readonly code: string; + readonly hint?: string; +} + +export interface Tutorial { + readonly id: string; + readonly title: string; + readonly description: string; + readonly difficulty: "beginner" | "intermediate" | "advanced"; + readonly estimatedMinutes: number; + readonly steps: readonly TutorialStep[]; +} + +export const TUTORIALS: readonly Tutorial[] = [ + { + id: "hello-marty", + title: "Hello Marty!", + description: + "Meet your virtual robot and learn the basics. You will make Marty get ready and take first steps.", + difficulty: "beginner", + estimatedMinutes: 10, + steps: [ + { + title: "Say Hello", + description: + "Every program starts by importing Marty and creating a connection. In Mini Marty, we use a virtual connection — no physical robot needed!", + code: `from martypy import Marty + +# Create a virtual Marty +my_marty = Marty("virtual") + +# Make Marty get ready +await my_marty.get_ready() +print("Marty is ready!")`, + hint: 'The Marty("virtual") part creates a simulated robot that you can see in the 3D viewer.', + }, + { + title: "First Steps", + description: + "Now let's make Marty walk! The walk() command takes a number of steps as an argument.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# Walk 3 steps forward +await my_marty.walk(3) +print("Marty walked 3 steps!")`, + hint: "Try changing the number inside walk() to make Marty walk more or fewer steps.", + }, + { + title: "Stand Straight", + description: + "After walking, let's make Marty stand up straight. This is a good way to reset the robot's position.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() +await my_marty.walk(2) +await my_marty.stand_straight() +print("All done!")`, + }, + ], + }, + { + id: "dance-moves", + title: "Dance Moves", + description: + "Make Marty dance, wiggle, and celebrate! Learn how to chain multiple movement commands together.", + difficulty: "beginner", + estimatedMinutes: 15, + steps: [ + { + title: "Let's Dance!", + description: + "Marty loves to dance! The dance() command makes Marty perform a fun dance routine.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() +await my_marty.dance() +print("What a dance move!")`, + }, + { + title: "Wiggle and Celebrate", + description: + "Chain commands together to create a dance routine! Each command runs after the previous one finishes.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() +await my_marty.wiggle() +await my_marty.dance() +await my_marty.celebrate() +print("Party time!")`, + hint: "Commands with 'await' run one after another. Marty finishes each action before starting the next.", + }, + { + title: "Kick and Slide", + description: + "Marty can kick and slide! These commands take a direction parameter — left or right.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() +await my_marty.kick("left") +await my_marty.kick("right") +await my_marty.slide("left") +await my_marty.slide("right") +await my_marty.celebrate()`, + hint: "Try changing \"left\" to \"right\" to see the difference!", + }, + ], + }, + { + id: "sensing-the-world", + title: "Sensing the World", + description: + "Learn about Marty's sensors — check if feet are on the ground, measure distances, and read accelerometer data.", + difficulty: "intermediate", + estimatedMinutes: 15, + steps: [ + { + title: "Foot Sensors", + description: + "Marty has sensors on each foot that detect whether the foot is touching the ground.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +left_on_ground = my_marty.foot_on_ground("left") +right_on_ground = my_marty.foot_on_ground("right") + +print(f"Left foot on ground: {left_on_ground}") +print(f"Right foot on ground: {right_on_ground}")`, + hint: "In the virtual world, both feet start on the ground (True). Sensors update based on Marty's pose.", + }, + { + title: "Distance Sensor", + description: + "Marty has a distance sensor that measures how far objects are in front of it, in centimetres.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +distance = my_marty.get_distance_sensor() +print(f"Distance to nearest object: {distance} cm") + +# Make a decision based on distance +if distance > 50: + print("Path is clear!") + await my_marty.walk(2) +else: + print("Object detected! Turning...")`, + }, + { + title: "Accelerometer", + description: + "The accelerometer tells you how Marty is tilted. It measures gravity on three axes: x, y, and z.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +accel = my_marty.get_accelerometer() +print(f"X: {accel['x']:.1f}") +print(f"Y: {accel['y']:.1f}") +print(f"Z: {accel['z']:.1f}") + +# Y is about -9.8 when standing upright (gravity!) +if accel['y'] < -5: + print("Marty is standing upright!")`, + }, + ], + }, + { + id: "loops-and-control", + title: "Loops & Control Flow", + description: + "Use Python loops and conditionals to make Marty repeat actions and make decisions.", + difficulty: "intermediate", + estimatedMinutes: 20, + steps: [ + { + title: "Repeat with for loops", + description: + "Use a for loop to make Marty repeat an action multiple times.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# Walk forward 3 times, celebrating each time +for i in range(3): + print(f"Walk number {i + 1}") + await my_marty.walk(2) + +await my_marty.celebrate() +print("Finished all walks!")`, + hint: "range(3) creates the numbers 0, 1, 2 — so the loop runs 3 times.", + }, + { + title: "If-Else Decisions", + description: + "Use if-else statements to make Marty react to sensor readings.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +distance = my_marty.get_distance_sensor() + +if distance > 50: + print("Far away - walking forward!") + await my_marty.walk(3) +elif distance > 20: + print("Getting close - walking slowly") + await my_marty.walk(1) +else: + print("Too close! Kicking!") + await my_marty.kick("right")`, + }, + { + title: "Functions — Reuse Your Code", + description: + "Write your own functions to create reusable movement patterns.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +async def happy_dance(): + """Make Marty do a happy dance routine!""" + await my_marty.wiggle() + await my_marty.dance() + await my_marty.celebrate() + print("That was fun!") + +async def greeting(): + """Marty greets by walking and celebrating.""" + await my_marty.walk(2) + await my_marty.kick("left") + await my_marty.kick("right") + +# Call our custom functions +await greeting() +await happy_dance()`, + hint: "Functions let you name a group of commands. You can then call them by name whenever you want!", + }, + ], + }, + { + id: "python-power", + title: "Python Power", + description: + "Advanced Python techniques: variables, lists, and creating your own dance choreography.", + difficulty: "advanced", + estimatedMinutes: 25, + steps: [ + { + title: "Variables and Lists", + description: + "Store movement sequences in a list and play them back.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# Define a choreography as a list of moves +moves = ["walk", "kick", "wiggle", "dance", "celebrate"] + +for move in moves: + print(f"Performing: {move}") + # Use getattr to call the method by name + method = getattr(my_marty, move) + if move == "walk": + await method(2) + elif move == "kick": + await method("right") + else: + await method() + +print("Choreography complete!")`, + hint: "getattr() lets you call a method using its name as a string — very powerful!", + }, + { + title: "Eyes and Arms Control", + description: + "Control Marty's eye expressions and arm positions for more expressive programs.", + code: `from martypy import Marty + +my_marty = Marty("virtual") +await my_marty.get_ready() + +# Cycle through eye expressions +expressions = ["normal", "wide", "angry", "excited", "squint"] + +for expr in expressions: + print(f"Eyes: {expr}") + await my_marty.eyes(expr) + +# Wave with arms +print("Waving!") +await my_marty.arms(-45, 45) +await my_marty.arms(45, -45) +await my_marty.arms(0, 0) + +await my_marty.celebrate()`, + }, + { + title: "Put It All Together", + description: + "Create a full robot performance using everything you have learned!", + code: `from martypy import Marty + +my_marty = Marty("virtual") + +async def intro(): + await my_marty.get_ready() + await my_marty.eyes("excited") + print("Welcome to the Marty Show!") + +async def act_one(): + print("Act 1: The Walk") + for i in range(2): + await my_marty.walk(2) + await my_marty.lean("left") + await my_marty.lean("right") + +async def act_two(): + print("Act 2: The Dance") + await my_marty.eyes("wide") + await my_marty.dance() + await my_marty.circle_dance() + +async def finale(): + print("Finale!") + await my_marty.wiggle() + await my_marty.celebrate() + await my_marty.eyes("excited") + print("Thank you! \\U0001f389") + +# Run the show +await intro() +await act_one() +await act_two() +await finale()`, + }, + ], + }, +]; + +export function getTutorialById(id: string): Tutorial | undefined { + return TUTORIALS.find((t) => t.id === id); +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts new file mode 100644 index 0000000..be557f5 --- /dev/null +++ b/src/lib/navigation.ts @@ -0,0 +1,17 @@ +export interface NavItem { + readonly label: string; + readonly path: string; + readonly icon: string; +} + +export const NAV_ITEMS: readonly NavItem[] = [ + { label: "Home", path: "/", icon: "🏠" }, + { label: "Block Editor", path: "/block-editor", icon: "🧩" }, + { label: "Python Editor", path: "/python-editor", icon: "🐍" }, + { label: "Tutorials", path: "/tutorials", icon: "📚" }, + { label: "Challenges", path: "/challenges", icon: "🏆" }, +] as const; + +export function getActiveNavItem(pathname: string): NavItem | undefined { + return NAV_ITEMS.find((item) => item.path === pathname); +} diff --git a/src/lib/sidebar-config.ts b/src/lib/sidebar-config.ts new file mode 100644 index 0000000..758b8f4 --- /dev/null +++ b/src/lib/sidebar-config.ts @@ -0,0 +1,93 @@ +export interface SidebarItem { + readonly label: string; + readonly href?: string; +} + +export interface SidebarSection { + readonly title: string; + readonly items: readonly SidebarItem[]; +} + +const HOME_SECTIONS: readonly SidebarSection[] = [ + { + title: "Quick Start", + items: [ + { label: "Block Coding", href: "/block-editor" }, + { label: "Python Coding", href: "/python-editor" }, + { label: "Tutorials", href: "/tutorials" }, + ], + }, + { + title: "Recent", + items: [{ label: "No recent projects" }], + }, +]; + +const BLOCK_EDITOR_SECTIONS: readonly SidebarSection[] = [ + { + title: "Block Categories", + items: [ + { label: "Motion" }, + { label: "Sound" }, + { label: "Sensing" }, + { label: "Events" }, + { label: "Control" }, + { label: "Variables" }, + { label: "Operators" }, + ], + }, +]; + +const PYTHON_EDITOR_SECTIONS: readonly SidebarSection[] = [ + { + title: "Code Snippets", + items: [ + { label: "Walk" }, + { label: "Dance" }, + { label: "Kick" }, + { label: "Sensors" }, + ], + }, + { + title: "Documentation", + items: [{ label: "API Reference" }, { label: "Examples" }], + }, +]; + +const TUTORIALS_SECTIONS: readonly SidebarSection[] = [ + { + title: "Tutorial Topics", + items: [ + { label: "Getting Started" }, + { label: "Movement" }, + { label: "Sensors" }, + { label: "Loops" }, + { label: "Python Basics" }, + ], + }, +]; + +const CHALLENGES_SECTIONS: readonly SidebarSection[] = [ + { + title: "Difficulty", + items: [ + { label: "Beginner" }, + { label: "Intermediate" }, + { label: "Advanced" }, + ], + }, +]; + +const SECTION_MAP: Readonly> = { + "/": HOME_SECTIONS, + "/block-editor": BLOCK_EDITOR_SECTIONS, + "/python-editor": PYTHON_EDITOR_SECTIONS, + "/tutorials": TUTORIALS_SECTIONS, + "/challenges": CHALLENGES_SECTIONS, +}; + +export function getSidebarSections( + pathname: string, +): readonly SidebarSection[] { + return SECTION_MAP[pathname] ?? HOME_SECTIONS; +} diff --git a/src/lib/theme-context.tsx b/src/lib/theme-context.tsx new file mode 100644 index 0000000..2fd9b86 --- /dev/null +++ b/src/lib/theme-context.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from "react"; + +type Theme = "light" | "dark"; + +interface ThemeContextValue { + readonly theme: Theme; + readonly toggleTheme: () => void; +} + +const STORAGE_KEY = "mini-marty-theme"; + +const ThemeContext = createContext(null); + +function getStoredTheme(): Theme { + if (typeof window === "undefined") return "light"; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "dark" || stored === "light") return stored; + return "light"; +} + +function applyThemeToDocument(theme: Theme): void { + if (theme === "dark") { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } +} + +export function ThemeProvider({ + children, +}: { + readonly children: React.ReactNode; +}) { + const [theme, setTheme] = useState(getStoredTheme); + + useEffect(() => { + applyThemeToDocument(theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((prev) => { + const next = prev === "light" ? "dark" : "light"; + localStorage.setItem(STORAGE_KEY, next); + return next; + }); + }, []); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} From 6b1877641ec2374a32ca92656ea6483e7abbd781 Mon Sep 17 00:00:00 2001 From: Thando Mini Date: Tue, 2 Jun 2026 17:14:05 +0200 Subject: [PATCH 02/70] docs: production-ready design spec Add docs/superpowers/specs/2026-06-02-production-ready-design.md covering test-coverage, accessibility, security, performance, observability, deployment, and documentation targets for shipping Mini Marty to Vercel. Phased rollout: foundation -> coverage -> polish & docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitmodules | 4 + .../2026-06-02-production-ready-design.md | 267 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 .gitmodules create mode 100644 docs/superpowers/specs/2026-06-02-production-ready-design.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..41094ec --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "repos/mini-marty"] + path = repos/mini-marty + url = https://github.com/tzone85/mini-marty.git + branch = main diff --git a/docs/superpowers/specs/2026-06-02-production-ready-design.md b/docs/superpowers/specs/2026-06-02-production-ready-design.md new file mode 100644 index 0000000..b87e285 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-production-ready-design.md @@ -0,0 +1,267 @@ +--- +title: Mini Marty — Production Readiness Design +date: 2026-06-02 +status: draft +owner: Thando Mini +tags: [spec, production, accessibility, performance, security, testing] +--- + +# Mini Marty — Production Readiness Design + +## 1. Goal + +Take the working Mini Marty prototype (3D virtual robot + Blockly + Python-in-browser + tutorials/challenges) to production-grade on Vercel. Targets: + +| Axis | Target | +|------|--------| +| Test coverage | ≥ 80% statements/lines/functions/branches | +| Lighthouse (Perf/A11y/BP/SEO) | ≥ 90 on every public page | +| Accessibility | WCAG 2.2 AA on every interactive surface | +| Bundle (initial JS, gzipped) | ≤ 200 kB for first paint | +| Cold-load Python ready | ≤ 6 s on cable broadband | +| Error visibility | All client errors flow to Sentry with sourcemaps | +| Security | CSP without `'unsafe-inline'` for scripts, no inline `