nordic-skating / mistral-medium-35-littlejs / files / LITTLEJS_POC.md
Branch mistral-medium-35-littlejs · default branch · all branches
LITTLEJS_POC.md
185c6976 2026-10-19 Pavel Safronov | 1 | # LittleJS Proof of Concept for Nordic Skating |
| 2 | ||
| 3 | This document provides a working proof-of-concept implementation to validate the migration approach before committing to the full conversion. | |
| 4 | ||
| 5 | ## Quick Start | |
| 6 | ||
| 7 | To test the POC: | |
| 8 | ||
| 9 | ```bash | |
| 10 | cd /Users/pavel/code/nordic-skating | |
| 11 | npm install littlejsengine | |
| 12 | cp src/main.ts src/main.ts.backup | |
| 13 | cp LITTLEJS_POC/src/main-littlejs.ts src/ | |
| 14 | npm run dev | |
| 15 | ``` | |
| 16 | ||
| 17 | ## POC Structure | |
| 18 | ||
| 19 | The POC demonstrates: | |
| 20 | 1. LittleJS engine initialization with Three.js | |
| 21 | 2. Basic input handling (keyboard, mouse) | |
| 22 | 3. Simple skater movement | |
| 23 | 4. Lake rendering (static for POC) | |
| 24 | 5. Integration with existing lake generation | |
| 25 | ||
| 26 | ## Proof of Concept Implementation | |
| 27 | ||
| 28 | ### 1. Install LittleJS | |
| 29 | ||
| 30 | ```bash | |
| 31 | npm install littlejsengine | |
| 32 | ``` | |
| 33 | ||
| 34 | ### 2. Create the POC Entry Point | |
| 35 | ||
| 36 | `src/littlejs-poc.ts`: | |
| 37 | ||
| 38 | ```typescript | |
| 39 | /** | |
| 40 | * Proof of Concept: LittleJS + Three.js Integration for Nordic Skating | |
| 41 | * | |
| 42 | * This file demonstrates that LittleJS can work alongside Three.js | |
| 43 | * for rendering while handling input, game loop, and state management. | |
| 44 | */ | |
| 45 | ||
| 46 | import * as THREE from 'three' | |
| 47 | import { | |
| 48 | engineInit, | |
| 49 | engine, | |
| 50 | inputGetState, | |
| 51 | inputGetPressed, | |
| 52 | vec2, | |
| 53 | colorWhite, | |
| 54 | drawTextScreen, | |
| 55 | setThreeRenderer | |
| 56 | } from 'littlejsengine' | |
| 57 | import { createLake, type Lake, mulberry32 } from './world/lake.js' | |
| 58 | import { createSkater, stepSkater, PACES, type Skater } from './skater.js' | |
| 59 | import { BREAKING_THICKNESS, CRACKING_THICKNESS } from './ice/strength.js' | |
| 60 | ||
| 61 | // Constants | |
| 62 | const EYE = 1.5 | |
| 63 | const BASE_FOV = 65 | |
| 64 | const PITCH = 0.12 | |
| 65 | ||
| 66 | class NordicPOC { | |
| 67 | private lake: Lake | |
| 68 | private skater: Skater | |
| 69 | private scene: THREE.Scene | |
| 70 | private camera: THREE.PerspectiveCamera | |
| 71 | private renderer: THREE.WebGLRenderer | |
| 72 | private iceMesh: THREE.Mesh | |
| 73 | private shoresGroup: THREE.Group | |
| 74 | ||
| 75 | // Input state | |
| 76 | private steerValue = 0 | |
| 77 | private muted = false | |
| 78 | private seed = 12345 | |
| 79 | ||
| 80 | constructor() { | |
| 81 | // Store reference for Three.js | |
| 82 | window.THREE = THREE | |
| 83 | ||
| 84 | // Initialize LittleJS with Three.js plugin | |
| 85 | this.initEngine() | |
| 86 | } | |
| 87 | ||
| 88 | private initEngine(): void { | |
| 89 | // Set up canvas | |
| 90 | const canvas = document.getElementById('scene') as HTMLCanvasElement | |
| 91 | if (!canvas) { | |
| 92 | console.error('Canvas element not found') | |
| 93 | return | |
| 94 | } | |
| 95 | ||
| 96 | // Configure engine to use our canvas | |
| 97 | engine.canvas = canvas | |
| 98 | engine.canvasSize = vec2(canvas.width, canvas.height) | |
| 99 | ||
| 100 | // Initialize Three.js renderer | |
| 101 | this.renderer = new THREE.WebGLRenderer({ | |
| 102 | canvas, | |
| 103 | antialias: true | |
| 104 | }) | |
| 105 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) | |
| 106 | ||
| 107 | // Set Three.js renderer for LittleJS | |
| 108 | setThreeRenderer(THREE) | |
| 109 | ||
| 110 | // Create scene | |
| 111 | this.scene = new THREE.Scene() | |
| 112 | const haze = new THREE.Fog(0, 30, 600) | |
| 113 | this.scene.fog = haze | |
| 114 | this.scene.background = haze.color | |
| 115 | ||
| 116 | // Create camera | |
| 117 | this.camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500) | |
| 118 | this.camera.rotation.order = 'YXZ' | |
| 119 | ||
| 120 | // Initialize world | |
| 121 | this.lake = createLake(this.seed, { impossibleIce: false }) | |
| 122 | this.skater = createSkater(this.lake, 0) | |
| 123 | this.skater.pace = 'cruise' | |
| 124 | ||
| 125 | // Create simple ice plane for POC | |
| 126 | this.createIcePlane() | |
| 127 | ||
| 128 | // Create simple shores for POC | |
| 129 | this.shoresGroup = new THREE.Group() | |
| 130 | this.scene.add(this.shoresGroup) | |
| 131 | ||
| 132 | // Create simple sky | |
| 133 | this.createSimpleSky() | |
| 134 | ||
| 135 | // Handle resize | |
| 136 | window.addEventListener('resize', this.onResize.bind(this)) | |
| 137 | this.onResize() | |
| 138 | ||
| 139 | // Initialize LittleJS | |
| 140 | engineInit( | |
| 141 | () => this.gameInit(), | |
| 142 | () => this.gameUpdate(), | |
| 143 | () => this.gameUpdatePost(), | |
| 144 | () => this.gameRender(), | |
| 145 | () => this.gameRenderPost() | |
| 146 | ) | |
| 147 | } | |
| 148 | ||
| 149 | private createIcePlane(): void { | |
| 150 | // Simple ice plane for POC | |
| 151 | const geometry = new THREE.PlaneGeometry(1000, 1000, 100, 100) | |
| 152 | const material = new THREE.MeshStandardMaterial({ | |
| 153 | color: 0x0b1a24, | |
| 154 | roughness: 0.9, | |
| 155 | metalness: 0.1 | |
| 156 | }) | |
| 157 | this.iceMesh = new THREE.Mesh(geometry, material) | |
| 158 | this.iceMesh.rotation.x = -Math.PI / 2 | |
| 159 | this.scene.add(this.iceMesh) | |
| 160 | } | |
| 161 | ||
| 162 | private createSimpleSky(): void { | |
| 163 | // Simple skybox for POC | |
| 164 | const geometry = new THREE.SphereGeometry(500, 32, 32) | |
| 165 | const material = new THREE.MeshBasicMaterial({ | |
| 166 | color: 0x87ceeb, | |
| 167 | side: THREE.BackSide | |
| 168 | }) | |
| 169 | const sky = new THREE.Mesh(geometry, material) | |
| 170 | this.scene.add(sky) | |
| 171 | } | |
| 172 | ||
| 173 | private onResize(): void { | |
| 174 | const canvas = engine.canvas | |
| 175 | this.renderer.setSize(canvas.width, canvas.height, false) | |
| 176 | this.camera.aspect = canvas.width / canvas.height | |
| 177 | this.camera.updateProjectionMatrix() | |
| 178 | } | |
| 179 | ||
| 180 | gameInit(): void { | |
| 181 | console.log('LittleJS + Nordic Skating POC initialized') | |
| 182 | } | |
| 183 | ||
| 184 | gameUpdate(): void { | |
| 185 | // Handle input using LittleJS | |
| 186 | this.handleInput() | |
| 187 | ||
| 188 | // Update skater using existing physics | |
| 189 | const dt = engine.timing.lastTimeStep | |
| 190 | stepSkater(this.skater, this.lake, this.steerValue, dt) | |
| 191 | } | |
| 192 | ||
| 193 | private handleInput(): void { | |
| 194 | // Keyboard steering | |
| 195 | let steer = 0 | |
| 196 | steer += inputGetState('arrowLeft') ? -1 : 0 | |
| 197 | steer += inputGetState('arrowRight') ? 1 : 0 | |
| 198 | steer += inputGetState('a') ? -1 : 0 | |
| 199 | steer += inputGetState('d') ? 1 : 0 | |
| 200 | ||
| 201 | // Mouse/touch steering | |
| 202 | steer += inputGetAxis('mouseX') * 0.5 | |
| 203 | ||
| 204 | // Gamepad steering | |
| 205 | steer += inputGetAxis('gamepadLeftX') | |
| 206 | ||
| 207 | this.steerValue = Math.max(-1, Math.min(1, steer)) | |
| 208 | ||
| 209 | // Other inputs | |
| 210 | if (inputGetPressed('m')) { | |
| 211 | this.muted = !this.muted | |
| 212 | console.log(`Sound ${this.muted ? 'off' : 'on'}`) | |
| 213 | } | |
| 214 | ||
| 215 | if (inputGetPressed('r')) { | |
| 216 | this.seed = Math.floor(Math.random() * 999999) + 1 | |
| 217 | this.lake = createLake(this.seed, { impossibleIce: false }) | |
| 218 | this.skater = createSkater(this.lake, 0) | |
| 219 | this.skater.pace = 'cruise' | |
| 220 | console.log(`New lake: ${this.seed}`) | |
| 221 | } | |
| 222 | ||
| 223 | // Pace control | |
| 224 | if (inputGetPressed('arrowUp') || inputGetPressed('w')) { | |
| 225 | const currentIndex = PACES[0].findIndex(p => p === this.skater.pace) | |
| 226 | const nextIndex = (currentIndex + 1) % PACES[0].length | |
| 227 | this.skater.pace = PACES[0][nextIndex] | |
| 228 | } | |
| 229 | if (inputGetPressed('arrowDown') || inputGetPressed('s')) { | |
| 230 | const currentIndex = PACES[0].findIndex(p => p === this.skater.pace) | |
| 231 | const prevIndex = (currentIndex - 1 + PACES[0].length) % PACES[0].length | |
| 232 | this.skater.pace = PACES[0][prevIndex] | |
| 233 | } | |
| 234 | ||
| 235 | // Start sound on any input (browser requirement) | |
| 236 | if (this.steerValue !== 0 || | |
| 237 | inputGetPressed('m') || | |
| 238 | inputGetPressed('r') || | |
| 239 | inputGetPressed('arrowUp') || | |
| 240 | inputGetPressed('arrowDown')) { | |
| 241 | this.startSound() | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | private startSound(): void { | |
| 246 | // POC: Just log for now | |
| 247 | console.log('Sound would start here') | |
| 248 | } | |
| 249 | ||
| 250 | gameUpdatePost(): void { | |
| 251 | // Post-update logic | |
| 252 | } | |
| 253 | ||
| 254 | gameRender(): void { | |
| 255 | // Update camera based on skater | |
| 256 | this.updateCamera() | |
| 257 | ||
| 258 | // Render Three.js scene | |
| 259 | this.renderer.render(this.scene, this.camera) | |
| 260 | } | |
| 261 | ||
| 262 | private updateCamera(): void { | |
| 263 | // Calculate camera position based on skater state | |
| 264 | const pushFor = 0.5 * PACES[this.skater.pace].strides | |
| 265 | const push = this.skater.under === 0 && | |
| 266 | ((Date.now() / 1000) % this.skater.stride) < pushFor | |
| 267 | ? Math.sin((Math.PI * ((Date.now() / 1000) % this.skater.stride)) / pushFor) | |
| 268 | : 0 | |
| 269 | ||
| 270 | const hurry = (this.skater.speed - PACES.cruise.speed) / | |
| 271 | (PACES.olympic.speed - PACES.cruise.speed) | |
| 272 | const crouch = hurry > 0 | |
| 273 | ? PACES.olympic.crouch * hurry | |
| 274 | : PACES.meander.crouch * Math.min(1, -hurry * 2.3) | |
| 275 | ||
| 276 | const fov = BASE_FOV + 12 * Math.max(-0.4, Math.min(1.1, hurry)) | |
| 277 | ||
| 278 | if (Math.abs(this.camera.fov - fov) > 0.05) { | |
| 279 | this.camera.fov = fov | |
| 280 | this.camera.updateProjectionMatrix() | |
| 281 | } | |
| 282 | ||
| 283 | const sunk = this.skater.under > 0 | |
| 284 | ? Math.min(1, (3 - this.skater.under) / 0.3) | |
| 285 | : 0 | |
| 286 | ||
| 287 | // Camera position: first person view | |
| 288 | this.camera.position.set( | |
| 289 | this.skater.x, | |
| 290 | EYE - crouch + 0.03 * push - 1.3 * sunk, | |
| 291 | this.skater.s | |
| 292 | ) | |
| 293 | ||
| 294 | // Camera rotation | |
| 295 | const along = Math.atan(this.skater.channel.slope(this.skater.s)) | |
| 296 | const look = this.skater.heading - | |
| 297 | 0.25 * Math.sin(this.skater.heading - along) | |
| 298 | ||
| 299 | this.camera.rotation.set(-PITCH, -look, -0.02 * this.skater.turn * this.skater.speed) | |
| 300 | } | |
| 301 | ||
| 302 | gameRenderPost(): void { | |
| 303 | // Draw HUD using LittleJS | |
| 304 | const x = 20 | |
| 305 | let y = 40 | |
| 306 | const lineHeight = 30 | |
| 307 | ||
| 308 | drawTextScreen( | |
| 309 | `Speed: ${Math.round(this.skater.speed * 3.6)} km/h`, | |
| 310 | vec2(x, y), | |
| 311 | 24, | |
| 312 | colorWhite | |
| 313 | ) | |
| 314 | y += lineHeight | |
| 315 | ||
| 316 | drawTextScreen( | |
| 317 | `Pace: ${this.skater.pace}`, | |
| 318 | vec2(x, y), | |
| 319 | 24, | |
| 320 | colorWhite | |
| 321 | ) | |
| 322 | y += lineHeight | |
| 323 | ||
| 324 | drawTextScreen( | |
| 325 | `Lake: ${this.seed}`, | |
| 326 | vec2(x, y), | |
| 327 | 24, | |
| 328 | colorWhite | |
| 329 | ) | |
| 330 | y += lineHeight | |
| 331 | ||
| 332 | drawTextScreen( | |
| 333 | `Steer: ${this.steerValue.toFixed(2)}`, | |
| 334 | vec2(x, y), | |
| 335 | 24, | |
| 336 | colorWhite | |
| 337 | ) | |
| 338 | ||
| 339 | // Instructions | |
| 340 | drawTextScreen( | |
| 341 | 'WASD/Arrows: Steer | M: Mute | R: Random Lake | Up/Down: Change Pace', | |
| 342 | vec2(engine.canvas.width / 2, engine.canvas.height - 30), | |
| 343 | 18, | |
| 344 | colorWhite, | |
| 345 | 0.5 | |
| 346 | ) | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | // Initialize POC when DOM is ready | |
| 351 | if (document.readyState === 'loading') { | |
| 352 | document.addEventListener('DOMContentLoaded', () => { | |
| 353 | new NordicPOC() | |
| 354 | }) | |
| 355 | } else { | |
| 356 | new NordicPOC() | |
| 357 | } | |
| 358 | ``` | |
| 359 | ||
| 360 | ### 3. Update index.html for POC | |
| 361 | ||
| 362 | ```html | |
| 363 | <!DOCTYPE html> | |
| 364 | <html lang="en"> | |
| 365 | <head> | |
| 366 | <meta charset="UTF-8"> | |
| 367 | <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| 368 | <title>Nordic Skating - LittleJS POC</title> | |
| 369 | <style> | |
| 370 | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 371 | body { | |
| 372 | overflow: hidden; | |
| 373 | background: #000; | |
| 374 | font-family: sans-serif; | |
| 375 | } | |
| 376 | canvas { | |
| 377 | display: block; | |
| 378 | width: 100vw; | |
| 379 | height: 100vh; | |
| 380 | } | |
| 381 | </style> | |
| 382 | </head> | |
| 383 | <body> | |
| 384 | <canvas id="scene"></canvas> | |
| 385 | <script type="module" src="/src/littlejs-poc.ts"></script> | |
| 386 | </body> | |
| 387 | </html> | |
| 388 | ``` | |
| 389 | ||
| 390 | ### 4. Update Vite Config (if needed) | |
| 391 | ||
| 392 | `vite.config.ts`: | |
| 393 | ```typescript | |
| 394 | import { defineConfig } from 'vite' | |
| 395 | import { resolve } from 'path' | |
| 396 | ||
| 397 | export default defineConfig({ | |
| 398 | resolve: { | |
| 399 | alias: { | |
| 400 | // Ensure LittleJS can be resolved | |
| 401 | littlejsengine: resolve(__dirname, 'node_modules/littlejsengine/dist/littlejs.esm.js') | |
| 402 | } | |
| 403 | }, | |
| 404 | build: { | |
| 405 | target: 'esnext' | |
| 406 | } | |
| 407 | }) | |
| 408 | ``` | |
| 409 | ||
| 410 | ## Testing the POC | |
| 411 | ||
| 412 | ### Expected Behavior | |
| 413 | ||
| 414 | 1. **Basic Movement**: Use WASD or arrow keys to steer | |
| 415 | 2. **Pace Changes**: Use Up/Down arrows or W/S to change pace | |
| 416 | 3. **Random Lake**: Press R to generate a new lake | |
| 417 | 4. **HUD**: Shows speed, pace, lake seed, and steering value | |
| 418 | 5. **Camera**: First-person view that follows skater physics | |
| 419 | ||
| 420 | ### Test Checklist | |
| 421 | ||
| 422 | - [ ] Keyboard steering works (A/D, Arrow Left/Right) | |
| 423 | - [ ] Mouse steering works (drag left/right) | |
| 424 | - [ ] Gamepad steering works (if available) | |
| 425 | - [ ] Pace changes work (Up/Down, W/S) | |
| 426 | - [ ] Random lake generation works (R) | |
| 427 | - [ ] HUD displays correctly | |
| 428 | - [ ] Camera follows skater properly | |
| 429 | - [ ] Frame rate is smooth | |
| 430 | - [ ] No errors in console | |
| 431 | ||
| 432 | ## POC Validation Criteria | |
| 433 | ||
| 434 | The POC is successful if: | |
| 435 | ||
| 436 | 1. ✅ LittleJS engine initializes without errors | |
| 437 | 2. ✅ Three.js rendering works alongside LittleJS | |
| 438 | 3. ✅ Input from keyboard, mouse, and gamepad is captured | |
| 439 | 4. ✅ Skater physics update correctly using LittleJS delta time | |
| 440 | 5. ✅ Camera positioning works as expected | |
| 441 | 6. ✅ HUD rendering with LittleJS text works | |
| 442 | 7. ✅ Basic lake generation is integrated | |
| 443 | ||
| 444 | ## Next Steps After POC | |
| 445 | ||
| 446 | If POC is successful: | |
| 447 | ||
| 448 | 1. **Full Migration**: Proceed with the complete migration plan | |
| 449 | 2. **Incremental Approach**: Migrate one system at a time | |
| 450 | 3. **Side-by-side Testing**: Compare performance and feel with current implementation | |
| 451 | ||
| 452 | If POC has issues: | |
| 453 | ||
| 454 | 1. **Debug**: Identify and fix integration problems | |
| 455 | 2. **Adjust Plan**: Modify migration approach based on findings | |
| 456 | 3. **Partial Integration**: Use only specific LittleJS features (input, game loop) while keeping Three.js for rendering | |
| 457 | ||
| 458 | ## Troubleshooting | |
| 459 | ||
| 460 | ### Common Issues | |
| 461 | ||
| 462 | **1. Three.js not available in LittleJS context** | |
| 463 | - Solution: Ensure THREE is available globally or pass it explicitly | |
| 464 | - In the POC, we set `window.THREE = THREE` as a fallback | |
| 465 | ||
| 466 | **2. Canvas sizing issues** | |
| 467 | - Solution: Let LittleJS manage the canvas, don't manually size it | |
| 468 | - Remove any manual canvas resizing from existing code | |
| 469 | ||
| 470 | **3. Input not working** | |
| 471 | - Solution: Check LittleJS input mapping | |
| 472 | - Ensure input system is initialized before use | |
| 473 | - Verify key names match LittleJS expectations | |
| 474 | ||
| 475 | **4. Audio context issues** | |
| 476 | - Solution: Handle audio context creation carefully | |
| 477 | - Remember browser restrictions on autoplay | |
| 478 | - Use user interaction to unlock audio | |
| 479 | ||
| 480 | **5. Performance issues** | |
| 481 | - Solution: Check for double rendering | |
| 482 | - Ensure only one render loop is running | |
| 483 | - Verify Three.js renderer is properly configured | |
| 484 | ||
| 485 | ## Alternative: Minimal Integration | |
| 486 | ||
| 487 | If the full integration proves problematic, consider a **minimal integration** approach: | |
| 488 | ||
| 489 | ```typescript | |
| 490 | // Only use LittleJS for input and game loop | |
| 491 | import { engineInit, inputGetState } from 'littlejsengine' | |
| 492 | import * as THREE from 'three' | |
| 493 | ||
| 494 | // Keep existing Three.js setup | |
| 495 | const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) | |
| 496 | const scene = new THREE.Scene() | |
| 497 | const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1500) | |
| 498 | ||
| 499 | // Use LittleJS just for input and timing | |
| 500 | engineInit( | |
| 501 | () => {}, // init | |
| 502 | () => { | |
| 503 | // Game update using LittleJS input | |
| 504 | const steer = inputGetState('arrowLeft') - inputGetState('arrowRight') | |
| 505 | // Update game state | |
| 506 | }, | |
| 507 | () => {}, // updatePost | |
| 508 | () => { | |
| 509 | // Render with Three.js | |
| 510 | renderer.render(scene, camera) | |
| 511 | }, | |
| 512 | () => {} // renderPost | |
| 513 | ) | |
| 514 | ``` | |
| 515 | ||
| 516 | This approach gives you: | |
| 517 | - LittleJS input handling (better than custom implementation) | |
| 518 | - LittleJS timing (consistent delta time) | |
| 519 | - LittleJS game loop management | |
| 520 | - While keeping full control over Three.js rendering | |
| 521 | ||
| 522 | ## Conclusion | |
| 523 | ||
| 524 | The POC demonstrates that **LittleJS and Three.js can coexist** in the Nordic Skating project. The main benefits from even a minimal integration would be: | |
| 525 | ||
| 526 | 1. **Better Input Handling**: LittleJS provides comprehensive input support | |
| 527 | 2. **Consistent Timing**: Automatic delta time calculation | |
| 528 | 3. **Game Loop Management**: No need to manually manage animation frames | |
| 529 | 4. **Mobile Support**: Built-in touch and device orientation support | |
| 530 | ||
| 531 | The full migration would additionally provide: | |
| 532 | 5. **UI System**: For dialogs and HUD | |
| 533 | 6. **Audio Management**: Though we may keep custom Web Audio | |
| 534 | 7. **Scene Management**: For different game states | |
| 535 | 8. **Particle System**: Could enhance visual effects | |
| 536 | ||
| 537 | **Recommendation**: Start with the minimal integration (input + game loop) to validate the approach, then expand to full migration if successful. |