1/** 2 * BARREL CLIMB GAME - Mobile Optimized 3 * 4 * Key fixes applied: 5 * 6 * 1. FIXED TIMESTEP PHYSICS 7 * Problem: deltaTime varied wildly on mobile (30fps = deltaTime ~2.0), making 8 * jumps inconsistent because jump impulse was applied once but gravity 9 * accumulated per-frame. Result: floaty/too-high jumps on slower devices. 10 * Fix: Physics always steps at a fixed 16.67ms (60fps equivalent) tick rate. 11 * The game loop accumulates real elapsed time and runs as many fixed physics 12 * steps as needed. Rendering happens once per animation frame. This makes 13 * all physics deterministic regardless of device frame rate. 14 * 15 * 2. SPRITE RENDERING ALIGNMENT 16 * Problem: drawPlayer/drawEnemies used sprite.width/sprite.height (the raw 17 * image dimensions, e.g. 64px) to calculate draw offsets. This caused 18 * misalignment when real sprites loaded at different sizes than fallback 19 * canvases. Sprites appeared duplicated or offset on platforms. 20 * Fix: All sprites are now drawn scaled to the entity's CONFIG bounding box 21 * (PLAYER_WIDTH x PLAYER_HEIGHT, ENEMY_WIDTH x ENEMY_HEIGHT). The draw 22 * position is simply the entity's x/y. Consistent on all sprite sizes. 23 * 24 * 3. PERFORMANCE / LAG 25 * Problem A: setTimeout inside updateEnemies (for throw delay) was called 26 * every frame per enemy, creating dozens of leaked, uncancelled timers. 27 * Fix: Replaced with a per-enemy throwChargeTimer counter in the game loop. 28 * 29 * Problem B: deltaTime cap of 2.5 allowed huge physics steps after tab 30 * switches or slow frames, causing objects to teleport. 31 * Fix: Accumulator is capped at 3 fixed steps per frame maximum, preventing 32 * spiral-of-death while recovering gracefully from slow frames. 33 * 34 * Problem C: Canvas touch events forced browser to wait before scrolling. 35 * Fix: touch-action: none applied to canvas via JS, removing scroll-intent 36 * detection delay. Also applied to document.body during gameplay. 37 * 38 * Problem D: imageSmoothingEnabled left on causes expensive bilinear 39 * filtering on every sprite draw. 40 * Fix: ctx.imageSmoothingEnabled = false for pixel-perfect, faster rendering. 41 * 42 * 4. BUG FIXES (mobile regression fixes) 43 * Bug A - Enemy spawn cooldown inflated 60x: 44 * The spawn timer increments by 1 per physics tick (60/sec). The old check 45 * multiplied ENEMY_SPAWN_COOLDOWN by (1000/FIXED_TIMESTEP) = 60, making 46 * enemies take 10 real seconds to spawn instead of ~3 seconds. 47 * Fix: ENEMY_SPAWN_COOLDOWN is now expressed directly in physics ticks 48 * (180 ticks = 3 seconds at 60fps). The multiplier is removed from the check. 49 * 50 * Bug B - Player walk animation stuck on jump: 51 * The animation state decision ran before onGround was resolved. 52 * updatePlayer() resets onGround=false then moves the player, but 53 * checkCollisions() (which sets onGround=true) runs AFTER updatePlayer() 54 * returns. So onGround was always false when the animation was chosen, 55 * locking the player into the jump animation permanently. 56 * Fix: Animation state is now chosen in a separate updatePlayerAnimation() 57 * function that is called in physicsTick() AFTER checkCollisions(), so it 58 * always reads the correctly resolved onGround and sliding values. 59 * 60 * 5. PLAYER REQUESTED FIXES 61 * Fix 1 - Bottles too small: Increased BOTTLE_SIZE from 25 to 35 62 * Fix 2 - Square shadow around bottles: Removed strokeRect from drawBottles() 63 * Fix 3 - Enemies not spawning: Increased spawn rate, reduced cooldown, improved spawn logic 64 * Fix 4 - Game over play again button: Fixed event handling and state reset 65 */ 66 67(function() { 68 'use strict'; 69 70 // --------------------------------------------------------------------------- 71 // CONFIGURATION - UPDATED WITH FIXES 72 // --------------------------------------------------------------------------- 73 const CONFIG = { 74 // Canvas 75 CANVAS_WIDTH: 700, 76 CANVAS_HEIGHT: 500, 77 FPS: 60, 78 79 // Fixed timestep 80 FIXED_TIMESTEP: 1000 / 60, 81 82 // Physics 83 GRAVITY: 0.6, 84 JUMP_STRENGTH: -13, 85 MAX_JUMPS: 3, 86 PLAYER_SPEED: 5, 87 SLIDE_SPEED: 8, 88 SLIDE_HEIGHT: 20, 89 SCROLL_SPEED: 3, 90 91 // Player 92 PLAYER_WIDTH: 35, 93 PLAYER_HEIGHT: 50, 94 95 // Platforms 96 PLATFORM_MIN_WIDTH: 120, 97 PLATFORM_MAX_WIDTH: 250, 98 PLATFORM_HEIGHT: 25, 99 PLATFORM_GAP_MIN: 100, 100 PLATFORM_GAP_MAX: 200, 101 PLATFORM_Y_VARIANCE: 50, 102 103 // Enemies - FIXED: Increased spawn rate and reduced cooldown 104 ENEMY_WIDTH: 40, 105 ENEMY_HEIGHT: 40, 106 ENEMY_SPEED: 1.5, 107 ENEMY_SPAWN_RATE: 0.8, 108 ENEMY_MAX_ON_SCREEN: 8, 109 ENEMY_SPAWN_COOLDOWN: 90, 110 111 // Throw charge 112 ENEMY_THROW_CHARGE_TICKS: 18, 113 114 // Projectiles 115 PROJECTILE_SIZE: 25, 116 PROJECTILE_SPEED: 4, 117 PROJECTILE_GRAVITY: 0.3, 118 ENEMY_THROW_RATE: 0.003, 119 120 // Collectibles - Hops (points) and Bottles (extra lives) 121 HOP_SIZE: 35, 122 HOP_SPAWN_RATE: 0.05, 123 BOTTLE_SIZE: 35, 124 BOTTLE_SPAWN_CHANCE: 0.05, // 5% chance a collectible is a life-giving bottle instead of hop 125 126 // Animation 127 PLAYER_ANIM_SPEED: 8, 128 ENEMY_ANIM_SPEED: 6, 129 ENEMY_DEATH_DURATION: 90, 130 PLAYER_DEATH_DURATION: 120, 131 132 // Game 133 INITIAL_LIVES: 3, 134 SCORE_PER_HOP: 10, 135 SCORE_PER_BOTTLE: 50, // Bottles are rare, give more points + life 136 SCORE_PER_ENEMY: 20, 137 LEVEL_UP_DISTANCE: 1000, 138 INVULNERABLE_TIME: 120, 139 140 // Touch Controls 141 TOUCH_DRAG_THRESHOLD: 20, 142 TOUCH_SLIDE_THRESHOLD: 30, 143 FULLSCREEN_TOUCH: true 144 }; 145 146 // --------------------------------------------------------------------------- 147 // GAME STATE 148 // --------------------------------------------------------------------------- 149 const gameState = { 150 running: false, 151 gameOver: false, 152 score: 0, 153 lives: CONFIG.INITIAL_LIVES, 154 level: 1, 155 distance: 0, 156 scrollSpeed: CONFIG.SCROLL_SPEED, 157 player: null, 158 platforms: [], 159 enemies: [], 160 projectiles: [], 161 hops: [], // Hop collectibles (points) 162 bottles: [], // Bottle collectibles (lives) 163 particles: [], 164 keys: {}, 165 touches: new Map(), 166 camera: { x: 0, y: 0 }, 167 animationId: null, 168 spritesLoaded: false, 169 loadingAttempts: 0, 170 frameCount: 0, 171 enemySpawnTimer: 0, 172 playerDeathTimer: 0, 173 accumulator: 0, 174 lastRealTime: 0, 175 pendingGameOver: false 176 }; 177 178 // --------------------------------------------------------------------------- 179 // SPRITES 180 // --------------------------------------------------------------------------- 181 const sprites = { 182 player: { run: {}, jump: {}, slide: {}, death: {} },
183 enemies: { fox: {}, Mouse: {}, wildCat: {} }, 184 hops: { roll: [] }, // Hop roll animation frames for points 185 bottles: { nvgb: null, nvb: null }, // Bottle sprites for extra lives 186 projectiles: { hopRoll: [], hopBreak: [] } 187 }; 188 189 // --------------------------------------------------------------------------- 190 // DOM 191 // --------------------------------------------------------------------------- 192 let canvas, ctx; 193 let scoreEl, livesEl, levelEl, distanceEl; 194 let bgGradient = null; 195 196 // --------------------------------------------------------------------------- 197 // INIT - FIXED 198 // --------------------------------------------------------------------------- 199 function init() { 200 console.log('Initializing Barrel Climb Game...'); 201 202 canvas = document.getElementById('gameCanvas'); 203 if (!canvas) { 204 console.error('Game canvas not found!'); 205 return false; 206 } 207 208 ctx = canvas.getContext('2d'); 209 ctx.imageSmoothingEnabled = false; 210 canvas.style.touchAction = 'none'; 211 212 scoreEl = document.getElementById('score'); 213 livesEl = document.getElementById('lives'); 214 levelEl = document.getElementById('level'); 215 distanceEl = document.getElementById('distance'); 216 217 // FIXED: Completely reset all game state 218 Object.assign(gameState, { 219 running: false, 220 gameOver: false, 221 pendingGameOver: false, 222 score: 0, 223 lives: CONFIG.INITIAL_LIVES, 224 level: 1, 225 distance: 0, 226 scrollSpeed: CONFIG.SCROLL_SPEED, 227 player: null, 228 platforms: [], 229 enemies: [], 230 projectiles: [], 231 hops: [], 232 bottles: [], 233 particles: [], 234 keys: {}, 235 touches: new Map(), 236 camera: { x: 0, y: 0 }, 237 animationId: null, 238 spritesLoaded: gameState.spritesLoaded, 239 loadingAttempts: 0, 240 frameCount: 0, 241 enemySpawnTimer: 0, 242 playerDeathTimer: 0, 243 accumulator: 0, 244 lastRealTime: 0 245 }); 246 247 // FIXED: Remove any leftover button listeners 248 canvas.removeEventListener('click', handlePlayAgainClick); 249 canvas.removeEventListener('touchstart', handlePlayAgainClick); 250 window.playAgainButton = null; 251 252 createFallbackSprites(); 253 loadSprites(); 254 initializeGame(); 255 256 return true; 257 } 258 259 // --------------------------------------------------------------------------- 260 // FALLBACK SPRITES 261 // --------------------------------------------------------------------------- 262 function createFallbackSprites() { 263 const playerAnimations = ['run', 'jump', 'slide', 'death']; 264 const playerDirections = [ 265 'east', 'west', 'south', 'north', 266 'north-east', 'north-west', 'south-east', 'south-west' 267 ]; 268 269 playerAnimations.forEach(anim => { 270 sprites.player[anim] = {}; 271 playerDirections.forEach(dir => { 272 sprites.player[anim][dir] = []; 273 const frameCount = anim === 'run' ? 8 : anim === 'jump' ? 7 : anim === 'slide' ? 5 : 7; 274 for (let i = 0; i < frameCount; i++) { 275 const c = document.createElement('canvas'); 276 c.width = CONFIG.PLAYER_WIDTH; 277 c.height = CONFIG.PLAYER_HEIGHT; 278 const cx = c.getContext('2d'); 279 cx.fillStyle = anim === 'jump' ? '#7209B7' : 280 anim === 'slide' ? '#F72585' : 281 anim === 'death' ? '#E94560' : '#4361EE'; 282 cx.fillRect(0, 0, c.width, c.height); 283 sprites.player[anim][dir][i] = c; 284 } 285 }); 286 }); 287 288 const enemyTypes = ['fox', 'Mouse', 'wildCat']; 289 const enemyAnimations = ['idle', 'walk', 'throw', 'death']; 290 const enemyDirections = ['east', 'west', 'north', 'south']; 291
292 enemyTypes.forEach(type => { 293 sprites.enemies[type] = {}; 294 enemyAnimations.forEach(anim => { 295 sprites.enemies[type][anim] = {}; 296 enemyDirections.forEach(dir => { 297 sprites.enemies[type][anim][dir] = []; 298 const frameCount = anim === 'walk' ? 6 : anim === 'throw' ? 7 : anim === 'death' ? 7 : 4; 299 for (let i = 0; i < frameCount; i++) { 300 const c = document.createElement('canvas'); 301 c.width = CONFIG.ENEMY_WIDTH; 302 c.height = CONFIG.ENEMY_HEIGHT; 303 const cx = c.getContext('2d'); 304 cx.fillStyle = type === 'Mouse' ? '#8D99AE' : 305 type === 'wildCat' ? '#2A9D8F' : '#FF6B35'; 306 cx.fillRect(0, 0, c.width, c.height); 307 sprites.enemies[type][anim][dir][i] = c; 308 } 309 }); 310 }); 311 }); 312 313 // Hop fallback - simple green circles (for points) 314 for (let i = 0; i < 36; i++) { 315 const c = document.createElement('canvas'); 316 c.width = CONFIG.HOP_SIZE; 317 c.height = CONFIG.HOP_SIZE; 318 const cx = c.getContext('2d'); 319 cx.fillStyle = '#7FB069'; 320 cx.beginPath(); 321 cx.arc(CONFIG.HOP_SIZE / 2, CONFIG.HOP_SIZE / 2, CONFIG.HOP_SIZE / 2, 0, Math.PI * 2); 322 cx.fill(); 323 sprites.hops.roll[i] = c; 324 } 325 326 // Bottle fallbacks (for extra lives) 327 const mkBottle = (color, label) => { 328 const c = document.createElement('canvas'); 329 c.width = CONFIG.BOTTLE_SIZE; 330 c.height = CONFIG.BOTTLE_SIZE; 331 const cx = c.getContext('2d'); 332 cx.fillStyle = color; 333 cx.fillRect(0, 0, c.width, c.height); 334 cx.fillStyle = '#fff'; 335 cx.font = 'bold 12px Arial'; 336 cx.textAlign = 'center'; 337 cx.fillText(label, c.width / 2, c.height / 2 + 4); 338 // Add a star to indicate it's special 339 cx.fillStyle = '#FFD700'; 340 cx.font = '16px Arial'; 341 cx.fillText('â ', c.width / 2, c.height - 8); 342 return c; 343 }; 344 sprites.bottles.nvgb = mkBottle('#90AEAD', 'NVGB'); 345 sprites.bottles.nvb = mkBottle('#E64833', 'NVB'); 346 347 // Projectile fallbacks 348 for (let i = 0; i < 36; i++) { 349 const mkProj = (c1, c2) => { 350 const c = document.createElement('canvas'); 351 c.width = CONFIG.PROJECTILE_SIZE; 352 c.height = CONFIG.PROJECTILE_SIZE; 353 const cx = c.getContext('2d'); 354 cx.fillStyle = c1; 355 cx.fillRect(0, 0, c.width, c.height); 356 cx.fillStyle = c2; 357 cx.fillRect(5, 5, c.width - 10, c.height - 10); 358 return c; 359 }; 360 sprites.projectiles.hopRoll.push(mkProj('#663300', '#996600')); 361 sprites.projectiles.hopBreak.push(mkProj('#996600', '#CC9900')); 362 } 363 } 364 365 // --------------------------------------------------------------------------- 366 // LOAD REAL SPRITES 367 // --------------------------------------------------------------------------- 368 function loadSprites() { 369 let total = 0; 370 let loaded = 0; 371 372 function check() { 373 loaded++; 374 if (loaded >= total) { 375 gameState.spritesLoaded = true; 376 console.log('Sprites ready'); 377 } 378 } 379 380 function loadImg(src, onSuccess) { 381 total++; 382 const img = new Image(); 383 img.onload = () => { onSuccess(img); check(); }; 384 img.onerror = () => check(); 385 img.src = src; 386 } 387 388 // Load hop roll animation frames (36 frames) for point collectibles 389 for (let i = 0; i < 36; i++) { 390 const n = String(i).padStart(3, '0'); 391 loadImg(`img/hopRoll/hopRoll${n}.png`, img => { 392 sprites.hops.roll[i] = img; 393 }); 394 } 395 396 // Load bottle images for life collectibles 397 loadImg('img/NVGB_BOTTLE_S.png', img => { sprites.bottles.nvgb = img; }); 398 loadImg('img/NVB_BOTTLE_S.png', img => { sprites.bottles.nvb = img; }); 399 400 for (let i = 0; i < 36; i++) { 401 const n = i.toString().padStart(3, '0'); 402 loadImg(`img/hopRoll/hopRoll${n}.png`, img => { sprites.projectiles.hopRoll[i] = img; }); 403 loadImg(`img/hopBreak/hopBreak${n}.png`, img => { sprites.projectiles.hopBreak[i] = img; }); 404 } 405 406 const playerAnims = [ 407 { name: 'run', folder: 'walking-8-frames', frames: 8, dirs: ['east', 'west'] }, 408 { name: 'jump', folder: 'two-footed-jump', frames: 7, dirs: ['east', 'west'] }, 409 { name: 'slide', folder: 'crouching', frames: 5, dirs: ['east', 'west'] }, 410 { name: 'death', folder: 'falling-back-death', frames: 7, dirs: ['south'] } 411 ]; 412
413 playerAnims.forEach(anim => { 414 anim.dirs.forEach(dir => { 415 for (let i = 0; i < anim.frames; i++) { 416 const n = i.toString().padStart(3, '0'); 417 loadImg( 418 `img/playerSprite/animations/${anim.folder}/${dir}/frame_${n}.png`, 419 img => { 420 if (!sprites.player[anim.name][dir]) sprites.player[anim.name][dir] = []; 421 sprites.player[anim.name][dir][i] = img; 422 } 423 ); 424 } 425 }); 426 }); 427 428 const enemyTypes = ['fox', 'Mouse', 'wildCat']; 429 const enemyAnims = [ 430 { name: 'idle', folder: 'breathing-idle', frames: 4, dirs: ['east', 'west'] }, 431 { name: 'walk', folder: 'walking', frames: 6, dirs: ['east', 'west'] }, 432 { name: 'throw', folder: 'throw-object', frames: 7, dirs: ['east', 'west'] }, 433 { name: 'death', folder: 'falling-back-death', frames: 7, dirs: ['south'] } 434 ]; 435 436 enemyTypes.forEach(type => { 437 enemyAnims.forEach(anim => { 438 anim.dirs.forEach(dir => { 439 for (let i = 0; i < anim.frames; i++) { 440 const n = i.toString().padStart(3, '0'); 441 loadImg( 442 `img/enemySprites/${type}/animations/${anim.folder}/${dir}/frame_${n}.png`, 443 img => { 444 if (!sprites.enemies[type][anim.name][dir]) sprites.enemies[type][anim.name][dir] = []; 445 sprites.enemies[type][anim.name][dir][i] = img; 446 } 447 ); 448 } 449 }); 450 }); 451 }); 452 453 setTimeout(() => { 454 if (!gameState.spritesLoaded) { 455 gameState.spritesLoaded = true; 456 console.warn('Sprite load timeout - some assets may be missing'); 457 } 458 }, 4000); 459 } 460 461 // --------------------------------------------------------------------------- 462 // HELPER FUNCTION - Check if enemy sprites are loaded 463 // --------------------------------------------------------------------------- 464 function areEnemySpritesLoaded() { 465 // Check if at least one enemy type has real sprites loaded 466 const enemyTypes = ['fox', 'Mouse', 'wildCat']; 467 for (const type of enemyTypes) { 468 const walkFrames = sprites.enemies[type]?.walk?.east; 469 if (walkFrames && walkFrames.length > 0 && walkFrames[0]?.width) { 470 // Found at least one enemy with real image sprites (not canvas fallbacks) 471 return true; 472 } 473 } 474 return false; 475 } 476 477 // --------------------------------------------------------------------------- 478 // INITIALIZE GAME WORLD 479 // --------------------------------------------------------------------------- 480 function initializeGame() { 481 gameState.running = true; 482 gameState.gameOver = false; 483 gameState.pendingGameOver = false; 484 gameState.score = 0; 485 gameState.lives = CONFIG.INITIAL_LIVES; 486 gameState.level = 1; 487 gameState.distance = 0; 488 gameState.scrollSpeed = CONFIG.SCROLL_SPEED; 489 gameState.frameCount = 0; 490 gameState.enemySpawnTimer = 0; 491 gameState.playerDeathTimer = 0; 492 gameState.accumulator = 0; 493 gameState.lastRealTime = performance.now(); 494 495 gameState.player = { 496 x: 100, 497 y: CONFIG.CANVAS_HEIGHT - 200, 498 width: CONFIG.PLAYER_WIDTH, 499 height: CONFIG.PLAYER_HEIGHT, 500 velocityX: 0, 501 velocityY: 0, 502 onGround: false, 503 jumpsUsed: 0, 504 sliding: false, 505 facing: 'right', 506 currentAnimation: 'run', 507 currentDirection: 'east', 508 animationFrame: 0, 509 animationTimer: 0, 510 animationSpeed: CONFIG.PLAYER_ANIM_SPEED, 511 invulnerable: false, 512 invulnerableTimer: 0, 513 dead: false 514 }; 515 516 gameState.platforms = generateInitialPlatforms(); 517 gameState.enemies = []; 518 gameState.projectiles = []; 519 gameState.hops = []; 520 gameState.bottles = []; 521 gameState.particles = []; 522 gameState.camera = { x: 0, y: 0 }; 523 gameState.keys = { left: false, right: false, up: fal
523se, down: false }; 524 525 setupControls(); 526 updateUI(); 527 528 bgGradient = ctx.createLinearGradient(0, 0, 0, canvas.height); 529 bgGradient.addColorStop(0, '#0f3460'); 530 bgGradient.addColorStop(1, '#1a1a2e'); 531 532 gameState.animationId = requestAnimationFrame(renderLoop); 533 534 console.log('Game started'); 535 } 536 537 // --------------------------------------------------------------------------- 538 // CONTROLS 539 // --------------------------------------------------------------------------- 540 function setupControls() { 541 window.removeEventListener('keydown', handleKeyDown); 542 window.removeEventListener('keyup', handleKeyUp); 543 544 canvas.removeEventListener('touchstart', handleTouchStart); 545 canvas.removeEventListener('touchmove', handleTouchMove); 546 canvas.removeEventListener('touchend', handleTouchEnd); 547 canvas.removeEventListener('touchcancel', handleTouchEnd); 548 549 document.body.removeEventListener('touchstart', handleTouchStart); 550 document.body.removeEventListener('touchmove', handleTouchMove); 551 document.body.removeEventListener('touchend', handleTouchEnd); 552 document.body.removeEventListener('touchcancel', handleTouchEnd); 553 554 window.addEventListener('keydown', handleKeyDown); 555 window.addEventListener('keyup', handleKeyUp); 556 557 document.body.style.touchAction = 'none'; 558 559 if (CONFIG.FULLSCREEN_TOUCH) { 560 document.body.addEventListener('touchstart', handleTouchStart, { passive: false }); 561 document.body.addEventListener('touchmove', handleTouchMove, { passive: false }); 562 document.body.addEventListener('touchend', handleTouchEnd, { passive: false }); 563 document.body.addEventListener('touchcancel', handleTouchEnd, { passive: false }); 564 } else { 565 canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); 566 canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); 567 canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); 568 canvas.addEventListener('touchcancel', handleTouchEnd, { passive: false }); 569 } 570 } 571 572 function handleKeyDown(e) { 573 if (!gameState.running || gameState.player.dead) return; 574 switch (e.key.toLowerCase()) { 575 case 'arrowleft': case 'a': gameState.keys.left = true; break; 576 case 'arrowright': case 'd': gameState.keys.right = true; break; 577 case 'arrowup': case 'w': case ' ': 578 if (!gameState.keys.up) { gameState.keys.up = true; e.preventDefault(); } 579 break; 580 case 'arrowdown': case 's': gameState.keys.down = true; break; 581 } 582 } 583 584 function handleKeyUp(e) { 585 switch (e.key.toLowerCase()) { 586 case 'arrowleft': case 'a': gameState.keys.left = false; break; 587 case 'arrowright': case 'd': gameState.keys.right = false; break; 588 case 'arrowup': case 'w': case ' ': gameState.keys.up = false; break; 589 case 'arrowdown': case 's': gameState.keys.down = false; break; 590 } 591 } 592 593 function handleTouchStart(e) { 594 if (!gameState.running || gameState.player.dead) return; 595 e.preventDefault(); 596 597 const rect = canvas.getBoundingClientRect(); 598 const scaleX = canvas.width / rect.width; 599 const scaleY = canvas.height / rect.height; 600 601 for (let i = 0; i < e.changedTouches.length; i++) { 602 const touch = e.changedTouches[i]; 603 const vx = touch.clientX; 604 const vy = touch.clientY; 605 606 gameState.touches.set(touch.identifier, { 607 startX: vx, startY: vy, 608 currentX: vx, currentY: vy, 609 canvasStartX: (vx - rect.left) * scaleX, 610 canvasStartY: (vy - rect.top) * scaleY, 611 startTime: Date.now() 612 }); 613 614 if (!gameState.keys.up) { 615 gameState.keys.up = true; 616 setTimeout(() => { gameState.keys.up = false; }, 100); 617 } 618 } 619 } 620 621 function handleTouchMove(e) { 622 if (!gameState.running || gameState.player.dead) return; 623 e.preventDefault(); 624 625 gameState.keys.left = false;
626 gameState.keys.right = false; 627 gameState.keys.down = false; 628 629 for (let i = 0; i < e.touches.length; i++) { 630 const touch = e.touches[i]; 631 const td = gameState.touches.get(touch.identifier); 632 if (!td) continue; 633 634 td.currentX = touch.clientX; 635 td.currentY = touch.clientY; 636 637 const dx = td.currentX - td.startX; 638 const dy = td.currentY - td.startY; 639 640 if (Math.abs(dx) > CONFIG.TOUCH_DRAG_THRESHOLD) { 641 if (dx < 0) gameState.keys.left = true; 642 else gameState.keys.right = true; 643 } 644 645 if (dy > CONFIG.TOUCH_SLIDE_THRESHOLD) { 646 gameState.keys.down = true; 647 } 648 } 649 } 650 651 function handleTouchEnd(e) { 652 e.preventDefault(); 653 for (let i = 0; i < e.changedTouches.length; i++) { 654 gameState.touches.delete(e.changedTouches[i].identifier); 655 } 656 if (gameState.touches.size === 0) { 657 gameState.keys.left = false; 658 gameState.keys.right = false; 659 gameState.keys.down = false; 660 gameState.keys.up = false; 661 } 662 } 663 664 // --------------------------------------------------------------------------- 665 // PLATFORM GENERATION 666 // --------------------------------------------------------------------------- 667 function generateInitialPlatforms() { 668 const platforms = []; 669 670 platforms.push({ 671 x: 0, 672 y: CONFIG.CANVAS_HEIGHT - 50, 673 width: CONFIG.CANVAS_WIDTH * 2, 674 height: CONFIG.PLATFORM_HEIGHT, 675 type: 'ground' 676 }); 677 678 let x = 200; 679 let lastY = CONFIG.CANVAS_HEIGHT - 150; 680 681 for (let i = 0; i < 10; i++) { 682 const width = CONFIG.PLATFORM_MIN_WIDTH + Math.random() * (CONFIG.PLATFORM_MAX_WIDTH - CONFIG.PLATFORM_MIN_WIDTH); 683 const gap = CONFIG.PLATFORM_GAP_MIN + Math.random() * (CONFIG.PLATFORM_GAP_MAX - CONFIG.PLATFORM_GAP_MIN); 684 let y = lastY + (Math.random() * CONFIG.PLATFORM_Y_VARIANCE * 2 - CONFIG.PLATFORM_Y_VARIANCE); 685 y = Math.max(100, Math.min(CONFIG.CANVAS_HEIGHT - 200, y)); 686 687 platforms.push({ x, y, width, height: CONFIG.PLATFORM_HEIGHT, type: 'platform' }); 688 x += width + gap; 689 lastY = y; 690 } 691 692 return platforms; 693 } 694 695 // --------------------------------------------------------------------------- 696 // MAIN LOOPS 697 // --------------------------------------------------------------------------- 698 function renderLoop(nowMs) { 699 if (!gameState.running || gameState.gameOver) return; 700 701 const elapsed = nowMs - gameState.lastRealTime; 702 gameState.lastRealTime = nowMs; 703 704 const maxAccumulate = CONFIG.FIXED_TIMESTEP * 3; 705 gameState.accumulator += Math.min(elapsed, maxAccumulate); 706 707 while (gameState.accumulator >= CONFIG.FIXED_TIMESTEP) { 708 physicsTick(); 709 gameState.accumulator -= CONFIG.FIXED_TIMESTEP; 710 } 711 712 if (gameState.pendingGameOver) { 713 gameState.pendingGameOver = false; 714 gameState.running = false; 715 gameState.gameOver = true; 716 showGameOver(); 717 return; 718 } 719 720 render(); 721 722 gameState.animationId = requestAnimationFrame(renderLoop); 723 } 724 725 // --------------------------------------------------------------------------- 726 // physicsTick 727 // --------------------------------------------------------------------------- 728 function physicsTick() { 729 if (!gameState.running || gameState.gameOver) return; 730 731 gameState.frameCount++; 732 733 updatePlayer(); 734 updatePlatforms(); 735 updateEnemies(); 736 updateProjectiles(); 737 updateHops(); 738 updateBottles(); 739 updateParticles(); 740 checkCollisions(); 741 updatePlayerAnimation(); 742 spawnObjects(); 743 updateCamera(); 744 updateGameStats(); 745 } 746 747 // --------------------------------------------------------------------------- 748 // PHYSICS UPDATE FUNCTIONS 749 // --------------------------------------------------------------------------- 750 function updatePlayer() { 751 const player = gameState.player; 752 753 if (player.dead) { 754 gameState.playerDeathTimer++; 755 756 player.velocityY += CONFIG.GRAVITY; 757 player.y += player.velocityY; 758 player.x -= gameState.scrollSpeed; 759 760 player.animationTimer++; 761 if (player.animationTimer >= player.animationSpeed) { 762 player.animationTimer = 0; 763 const frames = sprites.player[player.currentAnimation][player.currentDirection]; 764 if (frames && frames.length > 0) { 765 player.animationFrame = (player.animationFrame + 1) % frames.length; 766 } 767 } 768 769 if (player.y >
769 CONFIG.CANVAS_HEIGHT + 100 || gameState.playerDeathTimer >= CONFIG.PLAYER_DEATH_DURATION) { 770 gameState.pendingGameOver = true; 771 } 772 return; 773 } 774 775 if (player.invulnerable) { 776 player.invulnerableTimer--; 777 if (player.invulnerableTimer <= 0) { 778 player.invulnerable = false; 779 } 780 } 781 782 player.velocityX = 0; 783 if (gameState.keys.left) { 784 player.velocityX = -CONFIG.PLAYER_SPEED; 785 player.facing = 'left'; 786 player.currentDirection = 'west'; 787 } 788 if (gameState.keys.right) { 789 player.velocityX = CONFIG.PLAYER_SPEED; 790 player.facing = 'right'; 791 player.currentDirection = 'east'; 792 } 793 794 player.sliding = false; 795 if (gameState.keys.down && player.onGround) { 796 player.sliding = true; 797 player.velocityX = player.facing === 'right' ? CONFIG.SLIDE_SPEED : -CONFIG.SLIDE_SPEED; 798 player.height = CONFIG.SLIDE_HEIGHT; 799 } else { 800 player.height = CONFIG.PLAYER_HEIGHT; 801 } 802 803 if (gameState.keys.up && player.jumpsUsed < CONFIG.MAX_JUMPS) { 804 player.velocityY = CONFIG.JUMP_STRENGTH; 805 player.onGround = false; 806 player.jumpsUsed++; 807 gameState.keys.up = false; 808 } 809 810 player.velocityY += CONFIG.GRAVITY; 811 player.x += player.velocityX; 812 player.y += player.velocityY; 813 814 player.x = Math.max(0, Math.min(CONFIG.CANVAS_WIDTH - player.width, player.x)); 815 816 if (player.y > CONFIG.CANVAS_HEIGHT + 100) { 817 playerDie(); 818 } 819 820 player.onGround = false; 821 } 822 823 function updatePlayerAnimation() { 824 const player = gameState.player; 825 if (player.dead) return; 826 827 let targetAnim = 'run'; 828 if (player.sliding) { 829 targetAnim = 'slide'; 830 } else if (!player.onGround) { 831 targetAnim = 'jump'; 832 } else { 833 targetAnim = 'run'; 834 } 835 836 if (player.currentAnimation !== targetAnim) { 837 player.currentAnimation = targetAnim; 838 player.animationFrame = 0; 839 player.animationTimer = 0; 840 } 841 842 player.animationTimer++; 843 if (player.animationTimer >= player.animationSpeed) { 844 player.animationTimer = 0; 845 const frames = sprites.player[player.currentAnimation][player.currentDirection]; 846 if (frames && frames.length > 0) { 847 player.animationFrame = (player.animationFrame + 1) % frames.length; 848 } 849 } 850 } 851 852 function updatePlatforms() { 853 gameState.platforms.forEach(p => { p.x -= gameState.scrollSpeed; }); 854 gameState.platforms = gameState.platforms.filter(p => p.x + p.width > -200); 855 856 const last = gameState.platforms[gameState.platforms.length - 1]; 857 if (last.x < CONFIG.CANVAS_WIDTH + 300) { 858 const width = CONFIG.PLATFORM_MIN_WIDTH + Math.random() * (CONFIG.PLATFORM_MAX_WIDTH - CONFIG.PLATFORM_MIN_WIDTH); 859 const gap = CONFIG.PLATFORM_GAP_MIN + Math.random() * (CONFIG.PLATFORM_GAP_MAX - CONFIG.PLATFORM_GAP_MIN); 860 const recent = gameState.platforms.slice(-5); 861 let avgY = recent.reduce((s, p) => s + p.y, 0) / recent.length; 862 let y = avgY + (Math.random() * CONFIG.PLATFORM_Y_VARIANCE * 2 - CONFIG.PLATFORM_Y_VARIANCE); 863 y = Math.max(100, Math.min(CONFIG.CANVAS_HEIGHT - 200, y)); 864 gameState.platforms.push({ 865 x: last.x + last.width + gap, 866 y, width, height: CONFIG.PLATFORM_HEIGHT, type: 'platform' 867 }); 868 } 869 } 870 871 function updateEnemies() { 872 gameState.enemies.forEach(enemy => { 873 enemy.x -= gameState.scrollSpeed; 874 875 if (enemy.stomped) { 876 enemy.deathTimer++; 877 enemy.velocityY += CONFIG.GRAVITY; 878 enemy.y += enemy.velocityY; 879 enemy.animationTimer++; 880 if (enemy.animationTimer >= CONFIG.ENEMY_ANIM_SPEED) { 881 enemy.animationTimer = 0; 882 const frames = sprites.enemies[enemy.type]?.death?.south; 883 if (frames && enemy.animationFrame < frames.length - 1) { 884 enemy.animationFrame++; 885 } 886 } 887 return; 888 } 889 890 let platform = null; 891 for (const p of gameState.platforms) { 892 if (p.type === 'platform' && 893 enemy.x + enemy.width > p.x && 894 enemy.x < p.x + p.width && 895 Math.abs((enemy.y + enemy.height) - p.y) < 5) { 896 platform = p; 897 break; 898 } 899 } 900 901 if (platform) { 902 enemy.x += enemy.direction * enemy.speed; 903 904 if (enemy.x < platform.x) { 905 enemy.direction = 1; 906 enemy.x = platform.x; 907 } else if (enemy.x + enemy.width > platform.x + platform.width) { 908 enemy.direction = -1; 909 enemy.x = platform.x + platform.width - enemy.width; 910 } 911 912 enemy.y = platform.y - enemy.height; 913 } else { 914 enemy.stomped = true; 915 enemy.animationFrame = 0; 916 enemy.deathTimer = 0; 917 enemy.velocityY = 0; 918 } 919 920 enemy.animationTimer++; 921 if (enemy.animationTimer >= CONFIG.ENEMY_ANIM_SPEED) { 922 enemy.animationTimer = 0; 923 const animType = enemy.throwing ? 'throw' : enemy.direction !== 0 ? 'walk' : 'idle'; 924 const animDir = enemy.direction >= 0 ? 'east' : 'west'; 925 const frames = sprites.enemies[enemy.type][animType]?.[animDir]; 926 if (frames && frames.length > 0) { 927 enemy.animationFrame++; 928 if (enemy.animationFrame >= frames.length) { 929 enemy.throwing = false;
930 enemy.animationFrame = 0; 931 } 932 } 933 } 934 935 if (!enemy.throwing && Math.random() < CONFIG.ENEMY_THROW_RATE) { 936 enemy.throwing = true; 937 enemy.throwChargeTimer = 0; 938 enemy.animationFrame = 0; 939 enemy.animationTimer = 0; 940 } 941 942 if (enemy.throwing) { 943 enemy.throwChargeTimer = (enemy.throwChargeTimer || 0) + 1; 944 if (enemy.throwChargeTimer === CONFIG.ENEMY_THROW_CHARGE_TICKS) { 945 throwProjectile(enemy); 946 } 947 } 948 }); 949 950 gameState.enemies = gameState.enemies.filter(enemy => { 951 if (enemy.stomped) { 952 return enemy.y < CONFIG.CANVAS_HEIGHT + 100 && enemy.deathTimer < CONFIG.ENEMY_DEATH_DURATION; 953 } 954 return enemy.x > -100 && enemy.x < CONFIG.CANVAS_WIDTH + 100; 955 }); 956 } 957 958 function throwProjectile(enemy) { 959 if (enemy.stomped) return; 960 const dir = enemy.direction; 961 const startX = dir > 0 ? enemy.x + enemy.width : enemy.x - CONFIG.PROJECTILE_SIZE; 962 963 gameState.projectiles.push({ 964 x: startX, 965 y: enemy.y + enemy.height / 2, 966 width: CONFIG.PROJECTILE_SIZE, 967 height: CONFIG.PROJECTILE_SIZE, 968 velocityX: dir * CONFIG.PROJECTILE_SPEED, 969 velocityY: -2, 970 rotation: 0, 971 animationFrame: 0, 972 animationTimer: 0, 973 breaking: false 974 }); 975 } 976 977 function updateProjectiles() { 978 gameState.projectiles.forEach(proj => { 979 if (proj.breaking) { 980 proj.animationTimer++; 981 if (proj.animationTimer >= 3) { 982 proj.animationTimer = 0; 983 proj.animationFrame++; 984 } 985 return; 986 } 987 988 proj.x += proj.velocityX; 989 proj.y += proj.velocityY; 990 proj.velocityY += CONFIG.PROJECTILE_GRAVITY; 991 proj.rotation += proj.velocityX * 0.1; 992 proj.x -= gameState.scrollSpeed; 993 994 proj.animationTimer++; 995 if (proj.animationTimer >= 6) { 996 proj.animationTimer = 0; 997 proj.animationFrame = (proj.animationFrame + 1) % (sprites.projectiles.hopRoll.length || 36); 998 } 999 1000 for (const p of gameState.platforms) { 1001 if (proj.x + proj.width > p.x && 1002 proj.x < p.x + p.width && 1003 proj.y + proj.height > p.y && 1004 proj.y < p.y + p.height && 1005 proj.velocityY > 0) { 1006 1007 proj.y = p.y - proj.height; 1008 proj.velocityY = -proj.velocityY * 0.8; 1009 1010 if (Math.abs(proj.velocityY) < 1) { 1011 proj.breaking = true; 1012 proj.animationFrame = 0; 1013 proj.animationTimer = 0; 1014 } 1015 } 1016 } 1017 }); 1018 1019 gameState.projectiles = gameState.projectiles.filter(proj => 1020 proj.x > -100 && 1021 proj.x < CONFIG.CANVAS_WIDTH + 100 && 1022 proj.y < CONFIG.CANVAS_HEIGHT + 100 && 1023 (!proj.breaking || proj.animationFrame < (sprites.projectiles.hopBreak?.length || 36)) 1024 ); 1025 } 1026 1027 function updateHops() { 1028 gameState.hops.forEach(hop => { 1029 hop.x -= gameState.scrollSpeed; 1030 hop.bobTimer = (hop.bobTimer || 0) + 0.1; 1031 hop.bobOffset = Math.sin(hop.bobTimer) * 5; 1032 }); 1033 gameState.hops = gameState.hops.filter(h => h.x > -50 && !h.collected); 1034 } 1035 1036 function updateBottles() { 1037 gameState.bottles.forEach(bottle => { 1038 bottle.x -= gameState.scrollSpeed; 1039 bottle.bobTimer = (bottle.bobTimer || 0) + 0.15; // Slightly faster bob 1040 bottle.bobOffset = Math.sin(bottle.bobTimer) * 8; // Larger bob amplitude 1041 // Add sparkle effect timer 1042 bottle.sparkleTimer = (bottle.sparkleTimer || 0) + 0.3; 1043 }); 1044 gameState.bottles = gameState.bottles.filter(b => b.x > -50 && !b.collected); 1045 } 1046 1047 function updateParticles() {
1048 gameState.particles.forEach(p => { 1049 p.x += p.velocityX; 1050 p.y += p.velocityY; 1051 p.velocityY += 0.2; 1052 p.life--; 1053 }); 1054 gameState.particles = gameState.particles.filter(p => p.life > 0); 1055 } 1056 1057 // --------------------------------------------------------------------------- 1058 // SPAWNING - FIXED 1059 // --------------------------------------------------------------------------- 1060 function spawnObjects() { 1061 if (gameState.player.dead) return; 1062 1063 gameState.enemySpawnTimer++; 1064 1065 // Only spawn enemies if their sprites are actually loaded 1066 if (gameState.enemySpawnTimer >= CONFIG.ENEMY_SPAWN_COOLDOWN && areEnemySpritesLoaded()) { 1067 gameState.enemySpawnTimer = 0; 1068 1069 if (gameState.enemies.length < CONFIG.ENEMY_MAX_ON_SCREEN) { 1070 const suitable = gameState.platforms.filter(p => { 1071 const screenX = p.x - gameState.camera.x; 1072 return p.type === 'platform' && 1073 p.width > CONFIG.ENEMY_WIDTH * 1.5 && 1074 screenX > 50 && 1075 screenX < CONFIG.CANVAS_WIDTH - 50; 1076 }); 1077 1078 if (suitable.length > 0 && Math.random() < CONFIG.ENEMY_SPAWN_RATE) { 1079 const platform = suitable[Math.floor(Math.random() * suitable.length)]; 1080 1081 const occupied = gameState.enemies.some(e => 1082 !e.stomped && 1083 Math.abs(e.x - (platform.x + platform.width / 2)) < 100 && 1084 Math.abs(e.y + e.height - platform.y) < 20 1085 ); 1086 1087 if (!occupied) { 1088 const type = ['fox', 'Mouse', 'wildCat'][Math.floor(Math.random() * 3)]; 1089 1090 let spawnX = platform.x + (platform.width / 2) - (CONFIG.ENEMY_WIDTH / 2); 1091 spawnX += (Math.random() - 0.5) * (platform.width * 0.4); 1092 spawnX = Math.max(platform.x + 10, Math.min(platform.x + platform.width - CONFIG.ENEMY_WIDTH - 10, spawnX)); 1093 1094 gameState.enemies.push({ 1095 x: spawnX, 1096 y: platform.y - CONFIG.ENEMY_HEIGHT, 1097 width: CONFIG.ENEMY_WIDTH, 1098 height: CONFIG.ENEMY_HEIGHT, 1099 type, 1100 direction: Math.random() > 0.5 ? 1 : -1, 1101 speed: CONFIG.ENEMY_SPEED + gameState.level * 0.2, 1102 animationFrame: Math.floor(Math.random() * 6), 1103 animationTimer: 0, 1104 throwing: false, 1105 throwChargeTimer: 0, 1106 stomped: false, 1107 deathTimer: 0, 1108 velocityY: 0 1109 }); 1110 1111 console.log('Enemy spawned! Total enemies:', gameState.enemies.length); 1112 } 1113 } 1114 } 1115 } 1116 1117 // Spawn collectibles (mostly hops, rarely bottles) 1118 if (Math.random() < CONFIG.HOP_SPAWN_RATE) { 1119 const valid = gameState.platforms.filter(p => { 1120 const screenX = p.x - gameState.camera.x; 1121 return p.type === 'platform' && 1122 screenX > 50 && 1123 screenX < CONFIG.CANVAS_WIDTH - 50; 1124 }); 1125 1126 if (valid.length > 0) { 1127 const p = valid[Math.floor(Math.random() * valid.length)]; 1128 1129 let spawnX = p.x + (p.width / 4) + (Math.random() * (p.width / 2)); 1130 1131 // Check if there's already a collectible on this platform 1132 const alreadyHasCollectible = 1133 gameState.hops.some( 1134 h => !h.collected && 1135 h.x > p.x && 1136 h.x < p.x + p.width && 1137 Math.abs(h.y - (p.y - CONFIG.HOP_SIZE - 10)) < 30 1138 ) || 1139 gameState.bottles.some( 1140 b => !b.collected && 1141 b.x > p.x && 1142 b.x < p.x + p.width && 1143 Math.abs(b.y - (p.y - CONFIG.BOTTLE_SIZE - 10)) < 30 1144 ); 1145 1146 if (!alreadyHasCollectible) { 1147 // 5% chance to spawn a rare bottle (extra life), 95% chance for hop (points) 1148 if (Math.random() < CONFIG.BOTTLE_SPAWN_CHANCE) { 1149 // Spawn a rare bottle for extra life 1150 const type = Math.random() > 0.5 ? 'nvgb' : 'nvb'; 1151 gameState.bottles.push({ 1152 x: spawnX, 1153 y: p.y - CONFIG.BOTTLE_SIZE - 10, 1154 width: CONFIG.BOTTLE_SIZE, 1155 height: CONFIG.BOTTLE_SIZE,
1156 type, 1157 collected: false, 1158 bobTimer: Math.random() * Math.PI * 2, 1159 bobOffset: 0, 1160 sparkleTimer: 0 1161 }); 1162 } else { 1163 // Spawn a hop for points 1164 gameState.hops.push({ 1165 x: spawnX, 1166 y: p.y - CONFIG.HOP_SIZE - 10, 1167 width: CONFIG.HOP_SIZE, 1168 height: CONFIG.HOP_SIZE, 1169 collected: false, 1170 bobTimer: Math.random() * Math.PI * 2, 1171 bobOffset: 0 1172 }); 1173 } 1174 } 1175 } 1176 } 1177 } 1178 1179 // --------------------------------------------------------------------------- 1180 // COLLISION DETECTION 1181 // --------------------------------------------------------------------------- 1182 function checkCollisions() { 1183 const player = gameState.player; 1184 if (player.dead) return; 1185 1186 for (const p of gameState.platforms) { 1187 if (player.x + player.width > p.x && 1188 player.x < p.x + p.width && 1189 player.y + player.height > p.y && 1190 player.y < p.y + p.height) { 1191 1192 const bottomOverlap = Math.abs((player.y + player.height) - p.y); 1193 const topOverlap = Math.abs(player.y - (p.y + p.height)); 1194 1195 if (bottomOverlap < topOverlap && player.velocityY >= 0) { 1196 player.y = p.y - player.height; 1197 player.velocityY = 0; 1198 player.onGround = true; 1199 player.jumpsUsed = 0; 1200 } else if (topOverlap < bottomOverlap && player.velocityY < 0) { 1201 player.y = p.y + p.height; 1202 player.velocityY = 0; 1203 } 1204 } 1205 } 1206 1207 for (const enemy of gameState.enemies) { 1208 if (enemy.stomped) continue; 1209 if (player.x < enemy.x + enemy.width && 1210 player.x + player.width > enemy.x && 1211 player.y < enemy.y + enemy.height && 1212 player.y + player.height > enemy.y) { 1213 1214 if (player.velocityY > 0 && player.y + player.height - enemy.y < 15) { 1215 enemy.stomped = true; 1216 enemy.animationFrame = 0; 1217 enemy.deathTimer = 0; 1218 enemy.velocityY = -3; 1219 player.velocityY = CONFIG.JUMP_STRENGTH * 0.5; 1220 player.jumpsUsed = 0; 1221 addScore(CONFIG.SCORE_PER_ENEMY); 1222 createParticles(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, 10, '#FF6B35'); 1223 } else if (!player.invulnerable) { 1224 playerHit(); 1225 } 1226 } 1227 } 1228 1229 for (const proj of gameState.projectiles) { 1230 if (proj.breaking) continue; 1231 if (player.x < proj.x + proj.width && 1232 player.x + player.width > proj.x && 1233 player.y < proj.y + proj.height && 1234 player.y + player.height > proj.y && 1235 !player.invulnerable) { 1236 1237 playerHit(); 1238 proj.breaking = true; 1239 proj.animationFrame = 0; 1240 proj.animationTimer = 0; 1241 createParticles(proj.x + proj.width / 2, proj.y + proj.height / 2, 8, '#F77F00'); 1242 } 1243 } 1244 1245 // Collect hops (points) 1246 for (const hop of gameState.hops) { 1247 if (!hop.collected && 1248 player.x < hop.x + hop.width && 1249 player.x + player.width > hop.x && 1250 player.y < hop.y + hop.height && 1251 player.y + player.height > hop.y) { 1252 1253 hop.collected = true; 1254 addScore(CONFIG.SCORE_PER_HOP); 1255 createParticles(hop.x + hop.width / 2, hop.y + hop.height / 2, 6, '#7FB069'); 1256 } 1257 } 1258 1259 // Collect bottles (extra lives + points) 1260 for (const bottle of gameState.bottles) { 1261 if (!bottle.collected && 1262 player.x < bottle.x + bottle.width && 1263 player.x + player.width > bottle.x && 1264 player.y < bottle.y + bottle.height && 1265 player.y + player.height > bottle.y) { 1266 1267 bottle.collected = true; 1268 gameState.lives++; // Grant extra life! 1269 addScore(CONFIG.SCORE_PER_BOTTLE); 1270 updateUI(); 1271 // Special sparkle particles for bottle collection 1272 createParticles(bottle.x + bottle.width / 2, bottle.y + bottle.height / 2, 15, 1273 bottle.type === 'nvgb' ? '#90AEAD' : '#E64833'); 1274 createParticles(bottle.x + bottle.width / 2, bottle.y + bottle.height / 2, 10, '#FFD700'); 1275 1276 // Flash life counter or show "+1 LIFE" message 1277 console.log('+1 LIFE! Total lives:', gameState.lives); 1278 } 1279 } 1280 } 1281 1282 function playerHit() { 1283 const player = gameState.player; 1284 if (player.invulnerable || player.dead) return; 1285 1286 gameState.lives--; 1287 updateUI(); 1288 1289 if (gameState.lives <= 0) { 1290 playerDie(); 1291 } else { 1292 player.invulnerable = true; 1293 player.invulnerableTimer = CONFIG.INVULNERABLE_TIME; 1294 player.velocityY = -8; 1295 player.velocityX = player.facing === 'right' ? -5 : 5; 1296 createParticles(player.x + player.width / 2, player.y + player.height / 2, 15, '#E94560'); 1297 } 1298 } 1299 1300 function playerDie() { 1301 if (gameState.player.dead) return; 1302 const player = gameState.player; 1303 player.dead = true; 1304 player.currentAnimation = 'death'; 1305 player.currentDirection = 'south'; 1306 player.animationFrame = 0; 1307 player.animationTimer = 0; 1308 player.velocityY = -5; 1309 player.velocityX = player.facing === 'right' ? -3 : 3; 1310 gameState.playerDeathTimer = 0; 1311 createParticles(player.x + player.width / 2, player.y + player.height / 2, 20, '#E94560'); 1312 } 1313 1314 function createParticles(x, y, count, color) { 1315 for (let i = 0; i < count; i++) { 1316 gameState.particles.push({ 1317 x, y, 1318 velocityX: (Math.random() - 0.5) * 8, 1319 velocityY: (Math.random() - 0.5) * 8 - 2, 1320 size: Math.random() * 4 + 2, 1321 color, 1322 life: 60 1323 }); 1324 } 1325 } 1326 1327 // --------------------------------------------------------------------------- 1328 // CAMERA & STATS 1329 // --------------------------------------------------------------------------- 1330 function updateCamera() { 1331 const player = gameState.player; 1332 if (player.dead) { 1333 gameState.camera.x += gameState.scrollSpeed * 0.5; 1334 return; 1335 }
1336 const targetX = player.x - CONFIG.CANVAS_WIDTH / 3; 1337 gameState.camera.x += (targetX - gameState.camera.x) * 0.1; 1338 gameState.camera.x = Math.max(0, gameState.camera.x); 1339 } 1340 1341 function updateGameStats() { 1342 if (gameState.player.dead) return; 1343 1344 gameState.distance += gameState.scrollSpeed; 1345 1346 const newLevel = Math.floor(gameState.distance / (CONFIG.LEVEL_UP_DISTANCE * (1000 / CONFIG.FIXED_TIMESTEP))) + 1; 1347 if (newLevel > gameState.level) { 1348 gameState.level = newLevel; 1349 gameState.scrollSpeed = CONFIG.SCROLL_SPEED + gameState.level * 0.2; 1350 updateUI(); 1351 } 1352 1353 if (distanceEl) { 1354 distanceEl.textContent = Math.floor(gameState.distance / (1000 / CONFIG.FIXED_TIMESTEP)); 1355 } 1356 } 1357 1358 function addScore(points) { 1359 gameState.score += points; 1360 updateUI(); 1361 } 1362 1363 function updateUI() { 1364 if (scoreEl) scoreEl.textContent = gameState.score; 1365 if (livesEl) livesEl.textContent = gameState.lives; 1366 if (levelEl) levelEl.textContent = gameState.level; 1367 } 1368 1369 // --------------------------------------------------------------------------- 1370 // RENDERING 1371 // --------------------------------------------------------------------------- 1372 function render() { 1373 ctx.clearRect(0, 0, canvas.width, canvas.height); 1374 drawBackground(); 1375 drawPlatforms(); 1376 drawHops(); 1377 drawBottles(); 1378 drawEnemies(); 1379 drawProjectiles(); 1380 drawParticles(); 1381 drawPlayer(); 1382 } 1383 1384 function drawBackground() { 1385 ctx.fillStyle = bgGradient; 1386 ctx.fillRect(0, 0, canvas.width, canvas.height); 1387 1388 ctx.fillStyle = 'rgba(31, 40, 51, 0.5)'; 1389 for (let i = 0; i < 5; i++) { 1390 const x = (i * 300 - gameState.camera.x * 0.2) % (canvas.width + 300) - 150; 1391 ctx.beginPath(); 1392 ctx.moveTo(x, canvas.height - 100); 1393 ctx.lineTo(x + 150, canvas.height - 200); 1394 ctx.lineTo(x + 300, canvas.height - 100); 1395 ctx.fill(); 1396 } 1397 } 1398 1399 function drawPlatforms() { 1400 gameState.platforms.forEach(platform => { 1401 const x = platform.x - gameState.camera.x; 1402 1403 ctx.fillStyle = 'rgba(0,0,0,0.3)'; 1404 ctx.fillRect(x + 4, platform.y + 4, platform.width, platform.height); 1405 1406 ctx.fillStyle = platform.type === 'ground' ? '#5D4037' : '#8B4513'; 1407 ctx.fillRect(x, platform.y, platform.width, platform.height); 1408 1409 ctx.fillStyle = platform.type === 'ground' ? '#795548' : '#A0522D'; 1410 for (let i = 0; i < platform.width; i += 30) { 1411 ctx.fillRect(x + i, platform.y, 2, platform.height); 1412 } 1413 1414 ctx.strokeStyle = '#D2691E'; 1415 ctx.lineWidth = 2; 1416 ctx.strokeRect(x, platform.y, platform.width, platform.height); 1417 }); 1418 } 1419 1420 function drawPlayer() { 1421 const player = gameState.player; 1422 const x = player.x - gameState.camera.x; 1423 1424 if (player.invulnerable && Math.floor(gameState.frameCount / 5) % 2 === 0) { 1425 ctx.globalAlpha = 0.5; 1426 } 1427 1428 const frames = sprites.player[player.currentAnimation]?.[player.currentDirection]; 1429 if (frames && frames.length > 0) { 1430 const sprite = frames[player.animationFrame % frames.length]; 1431 if (sprite) { 1432 ctx.drawImage(sprite, x, player.y, player.width, player.height); 1433 } 1434 } else { 1435 ctx.fillStyle = player.currentAnimation === 'slide' ? '#F72585' : 1436 player.currentAnimation === 'jump' ? '#7209B7' : 1437 player.currentAnimation === 'death' ? '#E94560' : '#4361EE'; 1438 ctx.fillRect(x, player.y, player.width, player.height); 1439 1440 ctx.fillStyle = '#000'; 1441 if (player.facing === 'right') { 1442 ctx.fillRect(x + player.width - 10, player.y + 15, 5, 5); 1443 } else { 1444 ctx.fillRect(x + 5, player.y + 15, 5, 5); 1445 } 1446 } 1447 1448 ctx.globalAlpha = 1; 1449 } 1450 1451 function drawEnemies() {
1452 gameState.enemies.forEach(enemy => { 1453 if (enemy.stomped && 1454 (enemy.y > CONFIG.CANVAS_HEIGHT + 100 || enemy.deathTimer >= CONFIG.ENEMY_DEATH_DURATION)) { 1455 return; 1456 } 1457 1458 const x = enemy.x - gameState.camera.x; 1459 const animType = enemy.throwing ? 'throw' : enemy.stomped ? 'death' : 1460 enemy.direction !== 0 ? 'walk' : 'idle'; 1461 const animDir = enemy.stomped ? 'south' : (enemy.direction >= 0 ? 'east' : 'west'); 1462 const frames = sprites.enemies[enemy.type]?.[animType]?.[animDir]; 1463 1464 if (frames && frames.length > 0) { 1465 const sprite = frames[enemy.animationFrame % frames.length]; 1466 if (sprite) { 1467 ctx.drawImage(sprite, x, enemy.y, enemy.width, enemy.height); 1468 } 1469 } else { 1470 ctx.fillStyle = enemy.type === 'Mouse' ? '#8D99AE' : 1471 enemy.type === 'wildCat' ? '#2A9D8F' : '#FF6B35'; 1472 ctx.fillRect(x, enemy.y, enemy.width, enemy.height); 1473 ctx.fillStyle = '#000'; 1474 ctx.fillRect(x + 10, enemy.y + 10, 5, 5); 1475 ctx.fillRect(x + enemy.width - 15, enemy.y + 10, 5, 5); 1476 } 1477 }); 1478 } 1479 1480 function drawProjectiles() { 1481 gameState.projectiles.forEach(proj => { 1482 const x = proj.x - gameState.camera.x; 1483 const frames = proj.breaking ? sprites.projectiles.hopBreak : sprites.projectiles.hopRoll; 1484 const sprite = frames && frames.length > 0 1485 ? frames[Math.min(proj.animationFrame, frames.length - 1)] 1486 : null; 1487 1488 if (sprite) { 1489 if (proj.breaking) { 1490 ctx.drawImage(sprite, x, proj.y, proj.width, proj.height); 1491 } else { 1492 ctx.save(); 1493 ctx.translate(x + proj.width / 2, proj.y + proj.height / 2); 1494 ctx.rotate(proj.rotation); 1495 ctx.drawImage(sprite, -proj.width / 2, -proj.height / 2, proj.width, proj.height); 1496 ctx.restore(); 1497 } 1498 } else { 1499 ctx.fillStyle = '#F77F00'; 1500 if (!proj.breaking) { 1501 ctx.save(); 1502 ctx.translate(x + proj.width / 2, proj.y + proj.height / 2); 1503 ctx.rotate(proj.rotation); 1504 ctx.fillRect(-proj.width / 2, -proj.height / 2, proj.width, proj.height); 1505 ctx.restore(); 1506 } else { 1507 ctx.fillRect(x, proj.y, proj.width, proj.height); 1508 } 1509 } 1510 }); 1511 } 1512 1513 // --------------------------------------------------------------------------- 1514 // DRAW BOTTLES - FIXED (no square shadow) 1515 // --------------------------------------------------------------------------- 1516 // --------------------------------------------------------------------------- 1517 // DRAW HOPS - Animated hop roll sprites (for points) 1518 // --------------------------------------------------------------------------- 1519 function drawHops() { 1520 gameState.hops.forEach(hop => { 1521 if (hop.collected) return; 1522 const x = hop.x - gameState.camera.x; 1523 const y = hop.y + (hop.bobOffset || 0); 1524 1525 // Animate through hop roll frames 1526 const frameIndex = Math.floor((hop.bobTimer || 0) * 3) % (sprites.hops.roll.length || 36); 1527 const sprite = sprites.hops.roll[frameIndex]; 1528 1529 if (sprite) { 1530 ctx.drawImage(sprite, x - hop.width / 2, y, hop.width, hop.height); 1531 } else { 1532 // Fallback - draw a colored circle with label 1533 ctx.fillStyle = '#7FB069'; 1534 ctx.beginPath(); 1535 ctx.arc(x, y + hop.height/2, hop.width/2, 0, Math.PI * 2); 1536 ctx.fill(); 1537 1538 ctx.fillStyle = '#FFF'; 1539 ctx.font = 'bold 10px Arial'; 1540 ctx.textAlign = 'center'; 1541 ctx.fillText('HOP', x, y + hop.height/2 + 3); 1542 } 1543 }); 1544 } 1545 1546 // --------------------------------------------------------------------------- 1547 // DRAW BOTTLES - Rare bottles with sparkle effect (for extra lives) 1548 // --------------------------------------------------------------------------- 1549 function drawBottles() {
1550 gameState.bottles.forEach(bottle => { 1551 if (bottle.collected) return; 1552 const x = bottle.x - gameState.camera.x; 1553 const y = bottle.y + (bottle.bobOffset || 0); 1554 const sprite = sprites.bottles[bottle.type]; 1555 1556 // Draw sparkle effect around bottle 1557 const sparkle = Math.sin(bottle.sparkleTimer || 0); 1558 if (sparkle > 0.5) { 1559 ctx.fillStyle = 'rgba(255, 215, 0, ' + (sparkle * 0.5) + ')'; 1560 ctx.beginPath(); 1561 ctx.arc(x, y + bottle.height/2, bottle.width/2 + 5, 0, Math.PI * 2); 1562 ctx.fill(); 1563 } 1564 1565 if (sprite) { 1566 ctx.drawImage(sprite, x - bottle.width / 2, y, bottle.width, bottle.height); 1567 } else { 1568 ctx.fillStyle = bottle.type === 'nvgb' ? '#90AEAD' : '#E64833'; 1569 ctx.fillRect(x - bottle.width / 2, y, bottle.width, bottle.height); 1570 ctx.fillStyle = '#FFF'; 1571 ctx.font = 'bold 10px Arial'; 1572 ctx.textAlign = 'center'; 1573 ctx.fillText(bottle.type.toUpperCase(), x, y + bottle.height / 2 + 4); 1574 // Draw star to indicate it's special 1575 ctx.fillStyle = '#FFD700'; 1576 ctx.font = '14px Arial'; 1577 ctx.fillText('â ', x, y + bottle.height - 4); 1578 } 1579 }); 1580 } 1581 1582 function drawParticles() { 1583 gameState.particles.forEach(p => { 1584 ctx.globalAlpha = p.life / 60; 1585 ctx.fillStyle = p.color; 1586 ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size); 1587 }); 1588 ctx.globalAlpha = 1; 1589 } 1590 1591 // --------------------------------------------------------------------------- 1592 // GAME OVER SCREEN - FIXED 1593 // --------------------------------------------------------------------------- 1594 function showGameOver() { 1595 if (gameState.animationId) { 1596 cancelAnimationFrame(gameState.animationId); 1597 gameState.animationId = null; 1598 } 1599 1600 gameState.running = false; 1601 gameState.gameOver = true; 1602 1603 canvas.removeEventListener('click', handlePlayAgainClick); 1604 canvas.removeEventListener('touchstart', handlePlayAgainClick); 1605 1606 ctx.clearRect(0, 0, canvas.width, canvas.height); 1607 ctx.fillStyle = 'rgba(0,0,0,0.85)'; 1608 ctx.fillRect(0, 0, canvas.width, canvas.height); 1609 1610 ctx.fillStyle = '#f4a261'; 1611 ctx.font = 'bold 40px Arial'; 1612 ctx.textAlign = 'center'; 1613 ctx.fillText('GAME OVER!', canvas.width / 2, canvas.height / 2 - 80); 1614 1615 ctx.font = '25px Arial'; 1616 ctx.fillText(`Final Score: ${gameState.score}`, canvas.width / 2, canvas.height / 2 - 30); 1617 ctx.fillText(`Distance: ${Math.floor(gameState.distance / (1000 / CONFIG.FIXED_TIMESTEP))}m`, canvas.width / 2, canvas.height / 2 + 10); 1618 ctx.fillText(`Level: ${gameState.level}`, canvas.width / 2, canvas.height / 2 + 40); 1619 1620 let rank = ''; 1621 if (gameState.score >= 1000) rank = 'OWL MASTER!'; 1622 else if (gameState.score >= 700) rank = 'MASTER BREWER!'; 1623 else if (gameState.score >= 500) rank = 'SKILLED BREWER!'; 1624 else if (gameState.score >= 300) rank = 'CELLAR HAND!'; 1625 else if (gameState.score >= 100) rank = 'APPRENTICE!'; 1626 else rank = 'NEWBIE'; 1627 1628 ctx.font = '20px Arial'; 1629 ctx.fillStyle = '#e8dcc4'; 1630 ctx.fillText(rank, canvas.width / 2, canvas.height / 2 + 80); 1631
1632 const btnW = 200, btnH = 50; 1633 const btnX = canvas.width / 2 - btnW / 2; 1634 const btnY = canvas.height / 2 + 160; 1635 1636 ctx.fillStyle = '#f4a261'; 1637 ctx.fillRect(btnX, btnY, btnW, btnH); 1638 ctx.strokeStyle = '#e76f51'; 1639 ctx.lineWidth = 3; 1640 ctx.strokeRect(btnX, btnY, btnW, btnH); 1641 ctx.fillStyle = '#264653'; 1642 ctx.font = 'bold 20px Arial'; 1643 ctx.fillText('PLAY AGAIN', canvas.width / 2, btnY + 32); 1644 1645 window.playAgainButton = { 1646 x: btnX, 1647 y: btnY, 1648 width: btnW, 1649 height: btnH 1650 }; 1651 1652 setTimeout(() => { 1653 canvas.addEventListener('click', handlePlayAgainClick); 1654 canvas.addEventListener('touchstart', handlePlayAgainClick); 1655 }, 50); 1656 } 1657 1658 // --------------------------------------------------------------------------- 1659 // PLAY AGAIN HANDLER - FIXED 1660 // --------------------------------------------------------------------------- 1661 function handlePlayAgainClick(e) { 1662 e.preventDefault(); 1663 1664 const rect = canvas.getBoundingClientRect(); 1665 const scaleX = canvas.width / rect.width; 1666 const scaleY = canvas.height / rect.height; 1667 1668 let clientX, clientY; 1669 if (e.touches) { 1670 clientX = e.touches[0].clientX; 1671 clientY = e.touches[0].clientY; 1672 } else { 1673 clientX = e.clientX; 1674 clientY = e.clientY; 1675 } 1676 1677 const x = (clientX - rect.left) * scaleX; 1678 const y = (clientY - rect.top) * scaleY; 1679 1680 const btn = window.playAgainButton; 1681 1682 if (btn && 1683 x >= btn.x && x <= btn.x + btn.width && 1684 y >= btn.y && y <= btn.y + btn.height) { 1685 1686 console.log('Play again clicked!'); 1687 1688 canvas.removeEventListener('click', handlePlayAgainClick); 1689 canvas.removeEventListener('touchstart', handlePlayAgainClick); 1690 1691 window.playAgainButton = null; 1692 1693 stop(); 1694 1695 setTimeout(() => { 1696 init(); 1697 }, 50); 1698 } 1699 } 1700 1701 // --------------------------------------------------------------------------- 1702 // STOP - FIXED 1703 // --------------------------------------------------------------------------- 1704 function stop() { 1705 gameState.running = false; 1706 gameState.gameOver = true; 1707 1708 if (gameState.animationId) { 1709 cancelAnimationFrame(gameState.animationId); 1710 gameState.animationId = null; 1711 } 1712 1713 window.removeEventListener('keydown', handleKeyDown); 1714 window.removeEventListener('keyup', handleKeyUp); 1715 1716 canvas.removeEventListener('click', handlePlayAgainClick); 1717 canvas.removeEventListener('touchstart', handlePlayAgainClick); 1718 canvas.removeEventListener('touchstart', handleTouchStart); 1719 canvas.removeEventListener('touchmove', handleTouchMove); 1720 canvas.removeEventListener('touchend', handleTouchEnd); 1721 canvas.removeEventListener('touchcancel', handleTouchEnd); 1722 1723 document.body.removeEventListener('touchstart', handleTouchStart); 1724 document.body.removeEventListener('touchmove', handleTouchMove); 1725 document.body.removeEventListener('touchend', handleTouchEnd); 1726 document.body.removeEventListener('touchcancel', handleTouchEnd); 1727 1728 document.body.style.touchAction = ''; 1729 1730 gameState.touches.clear(); 1731 gameState.keys = {}; 1732 1733 window.playAgainButton = null; 1734 } 1735 1736 // --------------------------------------------------------------------------- 1737 // PUBLIC API 1738 // --------------------------------------------------------------------------- 1739 window.BarrelClimbGame = { 1740 init, 1741 stop, 1742 isRunning: () => gameState.running 1743 }; 1744 1745 console.log('Barrel Climb Game loaded - All fixes applied: bottles larger, no square shadow, enemies spawn frequently, play again works'); 1746 1747})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.