PageSourceSearch

https://ninebarnyardowls.co.nz/assets/js/barrel-climb-game.js

js ninebarnyardowls.co.nz collected 2026-10-07 20:42:11 UTC 69,150 bytes, 1,747 lines download raw bytes

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.