PageSourceSearch

https://dylanlandman.com/resources/js/game/game-world.js

js dylanlandman.com collected 2026-10-05 02:02:36 UTC 59,074 bytes, 1,578 lines download raw bytes

1// game-world.js
2// Game-specific world wrapper that uses the reusable CollisionWorld engine
3// and the WORLD_COLLIDERS data config to drive Dylan and world collisions.
4
5(function (global) {
6  if (typeof CollisionWorld === "undefined") {
7    console.error(
8      "CollisionWorld is not available. Did you load engine/collision-world.js?"
9    );
10    return;
11  }
12
13  // Sprite and collider dimensions for Dylan.
14  // These control both the physics body and the debug overlay.
15  var PLAYER_SPRITE_WIDTH = 32;
16  var PLAYER_SPRITE_HEIGHT = 64;
17  var PLAYER_COLLIDER_WIDTH = 24;
18  var PLAYER_COLLIDER_HEIGHT = 40;
19  var PLAYER_DEFAULT_SPEED = 2.2;
20
21  // World/map dimensions (in world units) – match #wrapper in CSS.
22  var WORLD_WIDTH = 2000;
23  var WORLD_HEIGHT = 1200;
24  // Visual map scale (matches CSS: transform: scale(3, 3))
25  var MAP_SCALE = 3;
26  // CSS zoom is visual-only; ignore it for camera math so centering isn't affected.
27  var MAP_ZOOM = 1;
28  // Effective scale used for camera math (keep equal to MAP_SCALE)
29  var EFFECTIVE_MAP_SCALE = MAP_SCALE;
30  // Intro settings
31  var INTRO_DURATION_MS = 500; // Reduced from 1300ms to 500ms for faster movement enable
32
33  class GameWorld {
34    constructor() {
35      this.collisionWorld = null;
36      this.sceneName = null;
37      this.player = null;
38      // Collider debug overlays default OFF
39      this._colliderDebugEnabled = false;
40      this._colliderDebugEls = [];
41      this._initialMapMarginLeft = 0;
42      this._initialMapMarginTop = 0;
43      this._spawn = { x: 0, y: 0 };
44      this._cameraOffset = { x: 0, y: 0 };
45      this._shakeOffset = { x: 0, y: 0 };
46      // Bonfire camera animation state
47      this._bonfireCameraTarget = null; // { x, y } when sitting, null when not
48      this._bonfireCameraCurrent = null; // Current interpolated position
49      // Star creation interval
50      this._starCreationInterval = null;
51      this._shootingStarInterval = null;
52      // Animation state
53      this._animFrame = 2;
54      this._animAccum = 0;
55      this._lastDir = "down"; // 'down' | 'up' | 'left' | 'right'
56      this._lastFacingApplied = null;
57      this._lastFrameApplied = null;
58      this._introPlayed = false;
59      // Player speed (configurable via settings)
60      this._playerSpeed = this._getInitialSpeed();
61    }
62    
63    /**
64     * Get initial player speed from cookie or default
65     */
66    _getInitialSpeed() {
67      if (typeof getCookie === "function") {
68        var savedSpeed = getCookie("character-speed");
69        if (savedSpeed) {
70          return parseFloat(savedSpeed);
71        }
72      }
73      return PLAYER_DEFAULT_SPEED;
74    }
75
76    /**
77     * Initialize the game world from WORLD_COLLIDERS for a given scene.
78     * sceneName: 'mainMap', 'tent1', etc.
79     */
80    init(sceneName) {
81      this._buildScene(sceneName, null);
82      // Hide player until intro completes
83      $("#dylan").css("visibility", "hidden");
84    }
85
86    /**
87     * Change to a different scene (e.g., enter tent/cave).
88     * options.spawnOverride can set a custom spawn {x,y} within the target scene.
89     */
90    changeScene(sceneName, options) {
91      options = options || {};
92      var spawnOverride = options.spawnOverride || null;
93
94      // Clear debug overlays from previous scene
95      this.clearColliderDebugOverlays();
96      
97      // Clear z-index debug overlays when changing scenes
98      if (typeof ZIndexManager !== "undefined") {
99        ZIndexManager.setDebugEnabled(false);
100      }
101      
102      // Reset z-index tracking when changing scenes
103      if (typeof ZIndexManager !== "undefined") {
104        ZIndexManager.reset();
105      }
106      
107      // Stop star creation when changing scenes
108      if (this._starCreationInterval) {
109        clearInterval(this._starCreationInterval);
110        this._starCreationInterval = null;
111      }
112      
113      // Stop shooting star creation when changing scenes
114      if (this._shootingStarInterval) {
115        clearInterval(this._shootingStarInterval);
116        this._shootingStarInterval = null;
117      }
118      
119      // Notify CloudManager of scene change
120      if (typeof CloudManager !== "undefined" && typeof CloudManager.onSceneChange === "function") {
121        CloudManager.onSceneChange(sceneName);
122      }
123
124      // Rebuild scene
125      this._buildScene(sceneName, spawnOverride);
126    }
127
128    /**
129     * Internal helper to build scene bodies/triggers/player.
130     */
131    _buildScene(sceneName, spawnOverride) {
132      if (typeof WORLD_COLLIDERS === "undefined") {
133        console.error("WORLD_COLLIDERS is not defined. Did you load world.config.js?");
134        return;
135      }
136
137      this.sceneName = sceneName || "mainMap";
138      this._setSceneVisibility(this.sceneName);
139      // Force debug overlays to regenerate on scene change
140      this._colliderDebugRendered = false;
141      // Build tent DOM if entering tent
142      if (this.sceneName === "tent1" && typeof buildTentDomIfNeeded === "function") {
143        buildTentDomIfNeeded();
144      }
145      // Build cave DOM if entering cave
146      if (this.sceneName === "cave" && typeof buildCaveDomIfNeeded === "function") {
147        buildCaveDomIfNeeded();
148        // Immediately update z-indexes after building cave DOM so signs have correct z-index on entry
149        if (this.player) {
150          this.updateZIndexes();
151        }
152      }
153      // Move Dylan to the active scene container
154      this._moveDylanToActiveScene();
155      var cfg = WORLD_COLLIDERS[this.sceneName];
156      if (!cfg) {
157        console.error("WORLD_COLLIDERS has no entry for scene:", this.sceneName);
158        return;
159      }
160
161      this._spawn = spawnOverride || cfg.playerSpawn || { x: 0, y: 0 };
162
163      // Capture initial map camera offsets so we can pan relative to spawn
164      var $parent = this._getSceneElement();
165      if ($parent.length) {
166        var ml = parseInt($parent.css("margin-left"), 10);
167        var mt = parseInt($parent.css("margin-top"), 10);
168        this._initialMapMarginLeft = isNaN(ml) ? 0 : ml;
169        this._initialMapMarginTop = isNaN(mt) ? 0 : mt;
170      } else {
171        this._initialMapMarginLeft = 0;
172        this._initialMapMarginTop = 0;
173      }
174
175      this.collisionWorld = new CollisionWorld({});
176
177      // Player spawn
178      var spawn = this._spawn;
179      this.player = this.collisionWorld.addPlayer({
180        x: spawn.x,
181        y: spawn.y,
182        width: PLAYER_COLLIDER_WIDTH,
183        height: PLAYER_COLLIDER_HEIGHT,
184        maxSpeed: this._playerSpeed
185      });
186
187      // Register solids
188      if (cfg.solids && cfg.solids.length) {
189        for (var i = 0; i < cfg.solids.length; i++) {
190          var s = cfg.solids[i];
191          this.collisionWorld.addSolidRect({
192            x: s.x,
193            y: s.y,
194            width: s.width,
195            height: s.height,
196            tag: s.tag
197          });
198        }
199      }
200
201      // Register triggers
202      if (cfg.triggers && cfg.triggers.length) {
203        for (var j = 0; j < cfg.triggers.length; j++) {
204          var t = cfg.triggers[j];
205          this.collisionWorld.addTriggerRect({
206            x: t.x,
207            y: t.y,
208            width: t.width,
209            height: t.height,
210            tag: t.tag
211          });
212        }
213      }
214
215      // Wire trigger-based scene transitions if provided in config
216      this._wireSceneTriggers(cfg);
217
218      // Example: wire tutorial trigger if present
219      var self = this;
220      this.collisionWorld.on("enter:tutorialArea", function () {
221        if (typeof openTutorial === "function") {
222          openTutorial();
223        }
224      });
225
226      // Initial sync of Dylan to physics position (do not auto-center; preserve manual offset)
227      this.syncToDom();
228
229      // Play intro drop only the first time we enter the main map
230      if (!this._introPlayed && this.sceneName === "mainMap") {
231        if (global.playerController) {
232          global.playerController.disableInput = true;
233        }
234        this._playIntroDrop();
235        this._introPlayed = true;
236      } else {
237        // Ensure input is enabled for all other scenes
238        if (global.playerController) {
239          global.playerController.disableInput = false;
240        }
241      }
242
243      // Initialize z-index manager for this scene
244      if (typeof ZIndexManager !== "undefined") {
245        ZIndexManager.init(this.sceneName);
246        // If debug was enabled, render debug overlays
247        if (this._colliderDebugEnabled) {
248          ZIndexManager.setDebugEnabled(true, this.sceneName);
249        }
250      }
251
252      // If debug was enabled before init, render overlays once
253      if (this._colliderDebugEnabled) {
254        this.renderColliderDebugOverlays();
255        this._colliderDebugRendered = true;
256      }
257    }
258
259    /**
260     * Advance physics and sync Dylan to DOM.
261     * input: { x: -1..1, y: -1..1 }
262     */
263    update(deltaMs, input) {
264      if (!this.collisionWorld) return;
265      var effectiveInput = input || { x: 0, y: 0 };
266      
267      // Check if fishing - maintain position and disable movement
268      var isFishing = (typeof window !== "undefined" && 
269                       ((typeof window.inFishing !== "undefined" && window.inFishing) ||
270                        (typeof window.isReelingIn !== "undefined" && window.isReelingIn)));
271      
272      if (isFishing && this.player) {
273        // Lock player to fishing position
274        this.player.setPosition({ x: 992, y: 1558 });
275        if (typeof this.player.setVelocity === "function") {
276          this.player.setVelocity({ x: 0, y: 0 });
277        }
278        effectiveInput = { x: 0, y: 0 };
279      }
280      
281      // Check if chopping tree - maintain position and disable movement
282      // Only lock if we have the axe (meaning we're chopping, not picking up the axe)
283      var isChoppingTree = (typeof window !== "undefined" && 
284                            typeof window.eventOccurence !== "undefined" && 
285                            window.eventOccurence && 
286                            typeof window.hitCount !== "undefined" && 
287                            window.hitCount < 7 &&
288                            typeof window.inventory !== "undefined" &&
289                            window.inventory.axe);
290      
291      if (isChoppingTree && this.player) {
292        // Lock player to tree chopping position (at actual tree location)
293        this.player.setPosition({ x: 242, y: 456 });
294        if (typeof this.player.setVelocity === "function") {
295          this.player.setVelocity({ x: 0, y: 0 });
296        }
297        effectiveInput = { x: 0, y: 0 };
298      }
299      
300      // Check if picking up axe - maintain position at stump
301      var isPickingUpAxe = (typeof window !== "undefined" && 
302                            typeof window.eventOccurence !== "undefined" && 
303                            window.eventOccurence && 
304                            typeof window.inventory !== "undefined" &&
305                            !window.inventory.axe);
306      
307      if (isPickingUpAxe && this.player) {
308        // Lock player to axe pickup position
309        this.player.setPosition({ x: 423, y: 434 });
310        if (typeof this.player.setVelocity === "function") {
311          this.player.setVelocity({ x: 0, y: 0 });
312        }
313        effectiveInput = { x: 0, y: 0 };
314      }
315      
316      // Check if input is disabled
317      var inputDisabled = false;
318      if (global.playerController && global.playerController.disableInput) {
319        effectiveInput = { x: 0, y: 0 };
320        inputDisabled = true;
321        if (this.player && typeof this.player.setVelocity === "function") {
322          this.player.setVelocity({ x: 0, y: 0 });
323        }
324      }
325      
326      // Store original input BEFORE any modifications (use the raw input parameter)
327      var originalInput = input ? { x: input.x || 0, y: input.y || 0 } : { x: 0, y: 0 };
328      
329      // Apply bonfire stairs modifier (migrated from old navigation.js)
330      // Bottom stairs: left/right movement also moves up/down (diagonal)
331      // Top stairs: vertical speed is increased when moving
332      // We bypass applyInput and set velocity directly when on stairs
333      var onStairs = false;
334      var stairsVelocity = null;
335      
336      if (this.sceneName === "mainMap" && this.player) {
337        var pos = this.player.getPosition();
338        var maxSpeed = this.player.maxSpeed || 4;
339        
340        // Bottom/Left stairs area: world coordinates converted from old CSS coordinates
341        // Old CSS: left 736-816, top 908-952 (exclusive: top < 952)
342        // World: x = CSS left + 16, y = CSS top + 44
343        var stairsBottomMinX = 752;
344        var stairsBottomMaxX = 832;
345        var stairsBottomMinY = 952;
346        var stairsBottomMaxY = 996;
347        
348        // Top/Front stairs area - exact coordinates: x: 972, y: 602, w: 56, h: 68
349        var stairsTopMinX = 972;
350        var stairsTopMaxX = 972 + 56;
351        var stairsTopMinY = 602;
352        var stairsTopMaxY = 602 + 68;
353        
354        // Check bottom stairs (diagonal movement: left+up, right+down)
355        if (pos.x >= stairsBottomMinX && pos.x <= stairsBottomMaxX &&
356            pos.y >= stairsBottomMinY && pos.y < stairsBottomMaxY) {
357          // On bottom bonfire stairs - set velocity directly for diagonal movement
358          // Only apply when moving horizontally (matches old behavior)
359          if (originalInput.x !== 0) {
360            onStairs = true;
361            // Calculate velocity maintaining 3:1 ratio from old frame-based movement
362            // Old: 12px/frame horizontal, 4px/frame vertical = 3:1 ratio
363            var stairsSpeed = maxSpeed * 2.5; // Scale to match old frame-based speed
364            var vx = originalInput.x * stairsSpeed;
365            // Vertical: left (x < 0) moves up (y < 0), right (x > 0) moves down (y > 0)
366            var vy = originalInput.x * stairsSpeed / 3; // 1/3 of horizontal speed
367            stairsVelocity = { x: vx, y: vy };
368          }
369        }
370        // Check top stairs (increased vertical speed)
371        else if (pos.x >= stairsTopMinX && pos.x <= stairsTopMaxX &&
372                 pos.y >= stairsTopMinY && pos.y <= stairsTopMaxY) {
373          // On top bonfire stairs - increase vertical speed when moving up/down
374          // Horizontal movement should work normally (no vertical component)
375          if (originalInput.y !== 0 || originalInput.x !== 0) {
376            onStairs = true;
377            // Calculate velocity with increased vertical speed
378            var stairsSpeed = maxSpeed * 2.5;
379            var vx = originalInput.x * stairsSpeed;
380            var vy = 0;
381            if (originalInput.y !== 0) {
382              // Moving up or down - increase vertical speed
383              vy = originalInput.y * stairsSpeed;
384            }
385            // If only moving horizontally, just use normal horizontal speed (no vertical component)
386            stairsVelocity = { x: vx, y: vy };
387          }
388        }
389      }
390      
391      // If on stairs, set velocity directly and skip applyInput
392      // Otherwise, use normal input
393      if (onStairs && stairsVelocity) {
394        // Set velocity BEFORE physics update so it's used during the update
395        if (this.player && typeof this.player.setVelocity === "function") {
396          this.player.setVelocity(stairsVelocity);
397        }
398        // Update physics with zero input (velocity already set above)
399        // But we need to prevent applyInput from overriding it
400        // So we'll manually update the engine without calling applyInput
401        if (this.collisionWorld && this.collisionWorld.engine) {
402          // Skip applyInput and just update the engine
403          var Engine = Matter.Engine;
404          Engine.update(this.collisionWorld.engine, deltaMs);
405        } else {
406          this.collisionWorld.update(deltaMs, { x: 0, y: 0 });
407        }
408        // Re-apply velocity after update in case it was modified
409        if (this.player && typeof this.player.setVelocity === "function") {
410          this.player.setVelocity(stairsVelocity);
411        }
412      } else {
413        // Normal movement - use regular input
414        this.collisionWorld.update(deltaMs, effectiveInput);
415      }
416      this.updateAnimation(deltaMs, effectiveInput);
417      this.syncToDom();
418      this.updateDoors();
419      this.updateZIndexes();
420      
421      // Update player debug box position every frame if debug is enabled
422      if (this._colliderDebugEnabled && this.player) {
423        this._updatePlayerDebugBox();
424      }
425
426      // Do not re-render debug overlays every frame; only on enable/scene change
427    }
428    
429    /**
430     * Update player debug box position to follow the character
431     */
432    _updatePlayerDebugBox() {
433      if (!this.player) return;
434      
435      // Find the player debug element
436      var $playerDebug = null;
437      for (var i = 0; i < this._colliderDebugEls.length; i++) {
438        var el = this._colliderDebugEls[i];
439        if (el && el.classList && el.classList.contains("world-collider-debug-player")) {
440          $playerDebug = $(el);
441          break;
442        }
443      }
444      
445      if (!$playerDebug || !$playerDebug.length) return;
446      
447      // Update position based on current player position
448      var pos = this.player.getPosition();
449      var footY = pos.y + PLAYER_COLLIDER_HEIGHT / 2;
450      var left = pos.x - PLAYER_COLLIDER_WIDTH / 2;
451      var top = footY - PLAYER_COLLIDER_HEIGHT;
452      
453      $playerDebug.css({
454        left: left + "px",
455        top: top + "px"
456      });
457    }
458
459    /**
460     * Update tent doors based on player position.
461     * Replicates legacy door logic from script.js move() function.
462     */
463    updateDoors() {
464      if (this.sceneName !== "mainMap") return;
465      if (!this.player) return;
466      
467      var $dylan = $("#dylan");
468      if (!$dylan.length) return;
469      
470      var dylanLeft = parseInt($dylan.css("left"));
471      
472      // Control tent1 door based on player position
473      // Door opens when player moves left (left < 428), closes when moving right (left >= 428)
474      if (typeof doorOpen === "undefined") {
475        global.doorOpen = false;
476      }
477      
478      if (!global.doorOpen) {
479        // Door is closed, check if we should open it
480        if (dylanLeft < 428) {
481          $(".tentdoor.tent1").animate({ "height": 0 }, 1000);
482          global.doorOpen = true;
483        }
484      } else {
485        // Door is open, check if we should close it
486        if (dylanLeft >= 428) {
487          $(".tentdoor.tent1").animate({ "height": 38 }, 1000);
488          global.doorOpen = false;
489        }
490      }
491    }
492
493    /**
494     * Update z-indexes of objects based on player position.
495     * Uses ZIndexManager to dynamically change z-indexes so player can walk behind/in front of objects.
496     */
497    updateZIndexes() {
498      if (!this.player) return;
499      if (typeof ZIndexManager === "undefined") return;
500      
501      // Get player position (center of collider)
502      var pos = this.player.getPosition();
503      
504      // Update z-indexes based on player position
505      ZIndexManager.update(pos.y, pos.x, this.sceneName);
506      
507      // Update timeline signs z-index individually based on their Y position
508      if (this.sceneName === "cave") {
509        var $signs = $(".cave-timeline-sign-interact");
510        $signs.each(function() {
511          var $sign = $(this);
512          var signY = parseFloat($sign.attr("data-sign-y")) || 0;
513          // Player z-index in cave is 9999
514          // Use player's center Y position for comparison
515          // signY is the sign's top Y position (stored in data-sign-y)
516          // In cave: Y increases as you go down
517          // When player Y > sign Y: player should be in front (player is below sign)
518          // When player Y <= sign Y: sign should be in front (player is at or above sign)
519          if (pos.y > signY) {
520            // Player center is below sign top - player should be in front (sign behind player)
521            $sign.css("z-index", "9998");
522          } else {
523            // Player center is at or above sign top - sign should be in front of player
524            $sign.css("z-index", "10000");
525          }
526        });
527      }
528      
529      // Update bonfire vignette effect
530      this.updateBonfireVignette(pos);
531    }
532    
533    /**
534     * Update bonfire vignette effect based on player position.
535     * Creates a cozy, focused atmosphere when player is in the bonfire area.
536     * Uses two vignette layers to darken both background and foreground elements.
537     */
538    updateBonfireVignette(pos) {
539      if (this.sceneName !== "mainMap") return;
540      
541      var $vignetteBottom = $("#bonfire-vignette-bottom");
542      var $vignetteMiddle = $("#bonfire-vignette-middle");
543      var $vignetteTop = $("#bonfire-vignette-top");
544      var $stars = $("#bonfire-stars");
545      if (!$vignetteBottom.length && !$vignetteMiddle.length && !$vignetteTop.length) return;
546      
547      // Bonfire area boundaries (adjusted to match collision bounds)
548      // Center is approximately at x: 984, y: 652
549      var bonfireMinX = 780;  // Left wall
550      var bonfireMaxX = 1292; // Right wall
551      var bonfireMinY = 650;  // Top cliff
552      var bonfireMaxY = 1068; // Bottom trees
553      
554      // Check if player is in bonfire area
555      var inBonfireArea = pos.x >= bonfireMinX && pos.x <= bonfireMaxX &&
556                         pos.y >= bonfireMinY && pos.y <= bonfireMaxY;
557            
558      // Toggle vignette class on all three layers
559      if (inBonfireArea) {
560        $vignetteBottom.addClass("active");
561        $vignetteMiddle.addClass("active");
562        $vignetteTop.addClass("active");
563        
564        // Show stars container
565        if ($stars.length) {
566          $stars.addClass("active");
567          
568          // Initialize star creation interval if not already set
569          if (!this._starCreationInterval) {
570            this._startStarCreation($stars);
571          }
572          
573          // Initialize shooting star creation if not already set
574          if (!this._shootingStarInterval) {
575            this._startShootingStarCreation($stars);
576          }
577        }
578      } else {
579        $vignetteBottom.removeClass("active");
580        $vignetteMiddle.removeClass("active");
581        $vignetteTop.removeClass("active");
582        
583        // Hide stars and stop creating new ones
584        if ($stars.length) {
585          $stars.removeClass("active");
586        }
587        
588        // Stop star creation
589        if (this._starCreationInterval) {
590          clearInterval(this._starCreationInterval);
591          this._starCreationInterval = null;
592        }
593        
594        // Stop shooting star creation
595        if (this._shootingStarInterval) {
596          clearInterval(this._shootingStarInterval);
597          this._shootingStarInterval = null;
598        }
599      }
600    }
601    
602    /**
603     * Start creating stars continuously while in bonfire area
604     */
605    _startStarCreation($container) {
606      var self = this;
607      var mapWidth = 2000;
608      var mapHeight = 1200;
609      
610      // Create a new star every 200-400ms
611      this._starCreationInterval = setInterval(function() {
612        // Remove old stars that have faded out (opacity 0 after animation)
613        $container.find(".bonfire-star").each(function() {
614          var $star = $(this);
615          // Check if animation has completed (stars fade out at 50% of 4s = 2s)
616          var animationDelay = parseFloat($star.css("animation-delay")) || 0;
617          var elapsed = (Date.now() - ($star.data("createdAt") || Date.now())) / 1000;
618          if (elapsed > 2 + animationDelay) {
619            $star.remove();
620          }
621        });
622        
623        // Create a new star
624        var $star = $("<div>").addClass("bonfire-star");
625        
626        // Random position across the map
627        var x = Math.random() * mapWidth;
628        var y = Math.random() * mapHeight;
629        
630        // Make some stars brighter (20% chance)
631        if (Math.random() < 0.2) {
632          $star.addClass("bright");
633        }
634        
635        // Random animation delay for variety (0-1s)
636        var delay = Math.random() * 1;
637        $star.css({
638          left: x + "px",
639          top: y + "px",
640          "animation-delay": delay + "s"
641        });
642        
643        // Track when star was created
644        $star.data("createdAt", Date.now());
645        
646        $container.append($star);
647        
648        // Remove star after animation completes (4s + delay)
649        setTimeout(function() {
650          $star.remove();
651        }, (4000 + delay * 1000));
652      }, 300); // Create a new star every 300ms
653    }
654    
655    /**
656     * Start creating shooting stars occasionally for ambiance
657     */
658    _startShootingStarCreation($container) {
659      var self = this;
660      var mapWidth = 2000;
661      var mapHeight = 1200;
662      
663      // Create a shooting star every 3-8 seconds
664      function createShootingStar() {
665        var $shootingStar = $("<div>").addClass("shooting-star");
666        
667        // Random starting position (top-left area for diagonal movement)
668        var startX = Math.random() * (mapWidth * 0.3); // Start in left 30% of map
669        var startY = Math.random() * (mapHeight * 0.2); // Start in top 20% of map
670        
671        $shootingStar.css({
672          left: startX + "px",
673          top: startY + "px"
674        });
675        
676        $container.append($shootingStar);
677        
678        // Remove after animation completes (2s)
679        setTimeout(function() {
680          $shootingStar.remove();
681        }, 2000);
682        
683        // Schedule next shooting star (3-8 seconds)
684        var nextDelay = 3000 + Math.random() * 5000;
685        self._shootingStarInterval = setTimeout(createShootingStar, nextDelay);
686      }
687      
688      // Start the first shooting star after a short delay
689      var initialDelay = 2000 + Math.random() * 3000;
690      this._shootingStarInterval = setTimeout(createShootingStar, initialDelay);
691    }
692
693    /**
694     * Push the physics player position into the #dylan DOM element.
695     * We treat the physics body position as the center of the collider,
696     * whose bottom edge roughly corresponds to Dylan's feet.
697     */
698    syncToDom() {
699      if (!this.player) return;
700      var pos = this.player.getPosition();
701      var $dylan = $("#dylan");
702      if ($dylan.length) {
703        // Compute foot position from collider center
704        var footY = pos.y + PLAYER_COLLIDER_HEIGHT / 2;
705        var left = pos.x - PLAYER_SPRITE_WIDTH / 2;
706        var top = footY - PLAYER_SPRITE_HEIGHT;
707        
708        // Set z-index based on scene
709        var currentZIndex = $dylan.css("z-index");
710        var cssUpdate = {
711          left: left + "px",
712          top: top + "px"
713        };
714        // Set z-index based on scene: 9999 in tent and cave, 49 on main map
715        // Special case: when sitting on any log/bench, set player z-index to 100
716        var isSittingOnTopBench = typeof window !== "undefined" && 
717                                   typeof window.onLogTop !== "undefined" && 
718                                   window.onLogTop === true;
719        var isSittingOnLeftStump = typeof window !== "undefined" && 
720                                   typeof window.onLogLeft !== "undefined" && 
721                                   window.onLogLeft === true;
722        var isSittingOnRightStump = typeof window !== "undefined" && 
723                                    typeof window.onLogRight !== "undefined" && 
724                                    window.onLogRight === true;
725        var isSittingOnAnyLog = isSittingOnTopBench || isSittingOnLeftStump || isSittingOnRightStump;
726        
727        if (this.sceneName === "tent1" || this.sceneName === "cave") {
728          if (currentZIndex !== "9999") {
729            cssUpdate["z-index"] = "9999";
730          }
731        } else if (this.sceneName === "mainMap") {
732          // When sitting on any log/bench, player should be above (z-index 100)
733          if (isSittingOnAnyLog) {
734            if (currentZIndex !== "100") {
735              cssUpdate["z-index"] = "100";
736            }
737          } else {
738            // Reset to 49 when on main map (correct z-index for player on main map)
739            if (currentZIndex !== "49") {
740              cssUpdate["z-index"] = "49";
741            }
742          }
743        }
744        $dylan.css(cssUpdate);
745      }
746
747      // Camera follow: keep player centered using world pos and effective scale.
748      var $parent = this._getSceneElement();
749      if ($parent.length) {
750        var sceneWidth = WORLD_WIDTH;
751        var sceneHeight = WORLD_HEIGHT;
752        
753        if (this.sceneName === "tent1") {
754          sceneWidth = 416;
755          sceneHeight = 1200;
756        } else if (this.sceneName === "cave") {
757          sceneWidth = 2000;
758          sceneHeight = 1200;
759        }
760        
761        var mapWidthPx = sceneWidth * EFFECTIVE_MAP_SCALE;
762        var mapHeightPx = sceneHeight * EFFECTIVE_MAP_SCALE;
763        
764        // Special handling for tent: center horizontally, follow vertically (no clamping)
765        if (this.sceneName === "tent1") {
766          var newMarginLeft = (window.innerWidth - mapWidthPx) / 2;
767          
768          // Follow player vertically with camera offset (no clamping in tent)
769          var viewportCenterY = window.innerHeight / 2;
770          var camY = pos.y;
771          var newMarginTop = viewportCenterY - camY * EFFECTIVE_MAP_SCALE + this._cameraOffset.y;
772          newMarginTop = Math.round(newMarginTop);
773          
774          // No clamping in tent - allow free camera movement
775          
776          $parent.css({
777            "margin-left": Math.round(newMarginLeft) + "px",
778            "margin-top": newMarginTop + "px"
779          });
780        }
781        // For small scenes (like tent) that fit within viewport, center the scene itself
782        else if (mapWidthPx <= window.innerWidth && mapHeightPx <= window.innerHeight) {
783          var newMarginLeft = (window.innerWidth - mapWidthPx) / 2;
784          var newMarginTop = (window.innerHeight - mapHeightPx) / 2;
785          
786          $parent.css({
787            "margin-left": Math.round(newMarginLeft) + "px",
788            "margin-top": Math.round(newMarginTop) + "px"
789          });
790        } else {
791          // For large scenes, follow the player with camera clamping
792          var viewportCenterX = window.innerWidth / 2;
793          var viewportCenterY = window.innerHeight / 2;
794
795          var camX, camY;
796          
797          // Check if player is sitting on a log - if so, smoothly center camera on bonfire instead of player
798          var isSitting = typeof window !== "undefined" && typeof window.onLog !== "undefined" && window.onLog;
799          var bonfireCenterX = 999;
800          var bonfireCenterY = 834;
801          
802          if (isSitting) {
803            // Set target for smooth camera movement
804            if (!this._bonfireCameraTarget) {
805              // Initialize target and current position
806              this._bonfireCameraTarget = { x: bonfireCenterX, y: bonfireCenterY };
807              // Start from current camera position (derived from current margin)
808              var $parent = this._getSceneElement();
809              if ($parent.length) {
810                var currentMarginLeft = parseInt($parent.css("margin-left")) || 0;
811                var currentMarginTop = parseInt($parent.css("margin-top")) || 0;
812                var viewportCenterX = window.innerWidth / 2;
813                var viewportCenterY = window.innerHeight / 2;
814                // Reverse the camera formula: worldPos = (viewportCenter - margin + offset) / scale
815                var currentWorldX = (viewportCenterX - currentMarginLeft + this._cameraOffset.x) / EFFECTIVE_MAP_SCALE;
816                var currentWorldY = (viewportCenterY - currentMarginTop + this._cameraOffset.y) / EFFECTIVE_MAP_SCALE;
817                this._bonfireCameraCurrent = { x: currentWorldX, y: currentWorldY };
818              } else {
819                this._bonfireCameraCurrent = { x: bonfireCenterX, y: bonfireCenterY };
820              }
821            } else {
822              // Update target in case it changed
823              this._bonfireCameraTarget = { x: bonfireCenterX, y: bonfireCenterY };
824            }
825            
826            // Smoothly interpolate towards target (lerp with factor 0.1 for smooth movement)
827            var lerpFactor = 0.1;
828            this._bonfireCameraCurrent.x += (this._bonfireCameraTarget.x - this._bonfireCameraCurrent.x) * lerpFactor;
829            this._bonfireCameraCurrent.y += (this._bonfireCameraTarget.y - this._bonfireCameraCurrent.y) * lerpFactor;
830            
831            camX = this._bonfireCameraCurrent.x;
832            camY = this._bonfireCameraCurrent.y;
833          } else {
834            // Not sitting - clear bonfire camera state and follow player
835            this._bonfireCameraTarget = null;
836            this._bonfireCameraCurrent = null;
837            camX = pos.x;
838            camY = pos.y;
839          }
840
841          // Optional micro-adjust to fine-tune visual centering
842          var NUDGE_X = 0; // negative shifts camera left;
842 adjust as needed
843          var NUDGE_Y = 0;  // negative shifts camera up; adjust as needed
844
845          // Always apply camera offset - it allows free panning even when snapped
846          // Also apply shake offset for screen shake effects
847          var newMarginLeft =
848            viewportCenterX - camX * EFFECTIVE_MAP_SCALE + this._cameraOffset.x + this._shakeOffset.x + NUDGE_X;
849          var newMarginTop =
850            viewportCenterY - camY * EFFECTIVE_MAP_SCALE + this._cameraOffset.y + this._shakeOffset.y + NUDGE_Y;
851
852          // Snap to whole pixels to avoid subpixel drift/lead
853          newMarginLeft = Math.round(newMarginLeft);
854          newMarginTop = Math.round(newMarginTop);
855
856          // Normal clamping for all areas
857          // Clamp so the camera never slides past the left or top page edges.
858          if (newMarginLeft > 0) {
859            newMarginLeft = 0;
860          }
861          
862          var minMarginLeft = window.innerWidth - mapWidthPx;
863          if (newMarginLeft < minMarginLeft) {
864            newMarginLeft = minMarginLeft;
865          }
866          
867          if (newMarginTop > 0) {
868            newMarginTop = 0;
869          }
870          
871          var bottomBufferPx = 0;
872          if (this.sceneName === "mainMap") {
873            bottomBufferPx = 2000; // allow slight extra downward pan on main map
874          }
875          var minMarginTop = window.innerHeight - mapHeightPx - bottomBufferPx;
876          if (newMarginTop < minMarginTop) {
877            newMarginTop = minMarginTop;
878          }
879
880          $parent.css({
881            "margin-left": newMarginLeft + "px",
882            "margin-top": newMarginTop + "px"
883          });
884        }
885      }
886    }
887
888    // ---- Collider debug overlays (WORLD_COLLIDERS-based) ----
889
890    setColliderDebugEnabled(enabled) {
891      this._colliderDebugEnabled = !!enabled;
892      this._colliderDebugRendered = false;
893      if (this._colliderDebugEnabled) {
894        this.renderColliderDebugOverlays();
895        this._colliderDebugRendered = true;
896        // Also enable z-index debug visualization
897        if (typeof ZIndexManager !== "undefined") {
898          ZIndexManager.setDebugEnabled(true, this.sceneName);
899        }
900      } else {
901        this.clearColliderDebugOverlays();
902        // Disable z-index debug visualization
903        if (typeof ZIndexManager !== "undefined") {
904          ZIndexManager.setDebugEnabled(false);
905        }
906      }
907    }
908
909    getColliderDebugEnabled() {
910      return !!this._colliderDebugEnabled;
911    }
912
913    /**
914     * Set player movement speed
915     */
916    setPlayerSpeed(speed) {
917      this._playerSpeed = speed;
918      // Update the player's maxSpeed directly
919      if (this.player) {
920        this.player.maxSpeed = speed;
921      }
922    }
923
924    /**
925     * Get current player speed
926     */
927    getPlayerSpeed() {
928      return this._playerSpeed;
929    }
930
931    clearColliderDebugOverlays() {
932      for (var i = 0; i < this._colliderDebugEls.length; i++) {
933        var el = this._colliderDebugEls[i];
934        if (el && el.parentNode) {
935          el.parentNode.removeChild(el);
936        }
937      }
938      this._colliderDebugEls = [];
939    }
940
941    renderColliderDebugOverlays() {
942      // Avoid rebuilding repeatedly each frame; only build when enabled/changed.
943      if (!this._colliderDebugEnabled) return;
944      // Clear existing overlays once before drawing
945      this.clearColliderDebugOverlays();
946      if (typeof WORLD_COLLIDERS === "undefined") return;
947
948      var cfg = WORLD_COLLIDERS[this.sceneName];
949      if (!cfg) return;
950
951      var parentSelector =
952        this.sceneName === "tent1" ? "#tent1" : this.sceneName === "cave" ? "#cave" : "#map";
953      var $parent = $(parentSelector);
954      if (!$parent.length) return;
955      
956      // Add purple debug overlay for bonfire stairs areas
957      if (this.sceneName === "mainMap") {
958        // Bottom/Left stairs area (stairs-left.campfire at CSS top: 752px, left: 752px)
959        // Old code checked: left 736-816, top 908-952
960        // World coordinates: x = CSS left + 16, y = CSS top + 44
961        var stairsLeftMinX = 752;
962        var stairsLeftMaxX = 832;
963        var stairsLeftMinY = 952;
964        var stairsLeftMaxY = 996;
965        var stairsLeftWidth = stairsLeftMaxX - stairsLeftMinX;
966        var stairsLeftHeight = stairsLeftMaxY - stairsLeftMinY;
967        
968        var $stairsLeftDebug = $("<div class='world-collider-debug world-collider-debug-stairs'></div>");
969        $stairsLeftDebug.css({
970          position: "absolute",
971          left: stairsLeftMinX + "px",
972          top: stairsLeftMinY + "px",
973          width: stairsLeftWidth + "px",
974          height: stairsLeftHeight + "px",
975          border: "2px dashed rgba(168, 85, 247, 0.9)", // purple
976          backgroundColor: "rgba(168, 85, 247, 0.25)", // purple with transparency
977          pointerEvents: "none",
978          zIndex: 999998
979        });
980        $stairsLeftDebug.attr("title", "Bonfire Left Stairs Area");
981        $parent.append($stairsLeftDebug);
982        this._colliderDebugEls.push($stairsLeftDebug[0]);
983        
984        // Top/Front stairs area - exact coordinates provided by user
985        // x: 972, y: 602, width: 56, height: 68
986        var stairsFrontMinX = 972;
987        var stairsFrontMaxX = 972 + 56;
988        var stairsFrontMinY = 602;
989        var stairsFrontMaxY = 602 + 68;
990        var stairsFrontWidth = 56;
991        var stairsFrontHeight = 68;
992        
993        var $stairsFrontDebug = $("<div class='world-collider-debug world-collider-debug-stairs'></div>");
994        $stairsFrontDebug.css({
995          position: "absolute",
996          left: stairsFrontMinX + "px",
997          top: stairsFrontMinY + "px",
998          width: stairsFrontWidth + "px",
999          height: stairsFrontHeight + "px",
1000          border: "2px dashed rgba(168, 85, 247, 0.9)", // purple
1001          backgroundColor: "rgba(168, 85, 247, 0.25)", // purple with transparency
1002          pointerEvents: "none",
1003          zIndex: 999998
1004        });
1005        $stairsFrontDebug.attr("title", "Bonfire Front/Top Stairs Area");
1006        $parent.append($stairsFrontDebug);
1007        this._colliderDebugEls.push($stairsFrontDebug[0]);
1008      }
1009
1010      // Solids
1011      if (cfg.solids && cfg.solids.length) {
1012        for (var i = 0; i < cfg.solids.length; i++) {
1013          var s = cfg.solids[i];
1014          var $el = $(
1015            "<div class='world-collider-debug world-collider-debug-solid'></div>"
1016          );
1017          $el.css({
1018            position: "absolute",
1019            left: s.x + "px",
1020            top: s.y + "px",
1021            width: s.width + "px",
1022            height: s.height + "px"
1023          });
1024          if (s.tag) {
1025            $el.attr("title", s.tag);
1026          }
1027          $parent.append($el);
1028          this._colliderDebugEls.push($el[0]);
1029        }
1030      }
1031
1032      // Triggers
1033      if (cfg.triggers && cfg.triggers.length) {
1034        for (var j = 0; j < cfg.triggers.length; j++) {
1035          var t = cfg.triggers[j];
1036          var $tEl = $(
1037            "<div class='world-collider-debug world-collider-debug-trigger'></div>"
1038          );
1039          $tEl.css({
1040            position: "absolute",
1041            left: t.x + "px",
1042            top: t.y + "px",
1043            width: t.width + "px",
1044            height: t.height + "px",
1045            background: "rgba(255,0,0,0.15)",
1046            border: "2px solid rgba(255,0,0,0.7)"
1047          });
1048          if (t.tag) {
1049            $tEl.attr("title", t.tag);
1050          }
1051          $parent.append($tEl);
1052          this._colliderDebugEls.push($tEl[0]);
1053        }
1054      }
1055
1056      // Timeline sign z-index transition points (show where z-index changes)
1057      if (this.sceneName === "cave" && typeof TIMELINE_ENTRIES !== "undefined") {
1058        TIMELINE_ENTRIES.forEach(function(entry, idx) {
1059          var signY = (entry.y || 0) - 10;
1060          // Show the Y position where z-index transitions (at signY)
1061          var $zIndexDebug = $(
1062            "<div class='world-collider-debug' style='border: 2px dashed rgba(255, 255, 0, 0.8); background-color: rgba(255, 255, 0, 0.1);'></div>"
1063          );
1064          $zIndexDebug.css({
1065            position: "absolute",
1066            left: "0px",
1067            top: signY + "px",
1068            width: "2000px",
1069            height: "2px"
1070          });
1071          $zIndexDebug.attr("title", "Z-Index Transition: Sign " + idx + " (" + (entry.year || "") + ") - Player behind above this line, in front below");
1072          $parent.append($zIndexDebug);
1073          this._colliderDebugEls.push($zIndexDebug[0]);
1074        }.bind(this));
1075      }
1076
1077      // Player hitbox (for reference)
1078      if (this.player) {
1079        var pos = this.player.getPosition();
1080        var footY = pos.y + PLAYER_COLLIDER_HEIGHT / 2;
1081        var left = pos.x - PLAYER_COLLIDER_WIDTH / 2;
1082        var top = footY - PLAYER_COLLIDER_HEIGHT;
1083        var $pEl = $(
1084          "<div class='world-collider-debug world-collider-debug-player'></div>"
1085        );
1086        $pEl.css({
1087          position: "absolute",
1088          left: left + "px",
1089          top: top + "px",
1090          width: PLAYER_COLLIDER_WIDTH + "px",
1091          height: PLAYER_COLLIDER_HEIGHT + "px"
1092        });
1093        $pEl.attr("title", "player");
1094        $parent.append($pEl);
1095        this._colliderDebugEls.push($pEl[0]);
1096      }
1097    }
1098
1099    // Utility: log current player center position to console for collider authoring
1100    logPlayerPositionOnce() {
1101      if (!this.player) return;
1102      var pos = this.player.getPosition();
1103      console.log(
1104        "WORLD_COLLIDER_POINT:",
1105        Math.round(pos.x),
1106        Math.round(pos.y)
1107      );
1108    }
1109
1110    // Utility: log player and camera state (for debugging)
1111    logCameraState() {
1112      if (!this.player) return;
1113      var pos = this.player.getPosition();
1114      var $parent = this._getSceneElement();
1115      var ml = $parent.length ? parseInt($parent.css("margin-left"), 10) || 0 : 0;
1116      var mt = $parent.length ? parseInt($parent.css("margin-top"), 10) || 0 : 0;
1117      console.log(
1118        "[CameraLog] player=", pos.x.toFixed(1), pos.y.toFixed(1),
1119        "offset=", this._cameraOffset.x.toFixed(1), this._cameraOffset.
1119y.toFixed(1),
1120        "margin=", ml.toFixed(1), mt.toFixed(1),
1121        "scene=", this.sceneName
1122      );
1123    }
1124
1125    // Utility: pan camera by a fixed offset (in world pixels)
1126    panCamera(dx, dy) {
1127      this._cameraOffset.x += dx;
1128      this._cameraOffset.y += dy;
1129      // Re-apply camera immediately
1130      this.syncToDom();
1131      if (this._colliderDebugEnabled) {
1132        this.renderColliderDebugOverlays();
1133      }
1134    }
1135
1136    // Utility: set camera offset absolutely (used for mouse dragging)
1137    setCameraOffset(x, y) {
1138      this._cameraOffset.x = x;
1139      this._cameraOffset.y = y;
1140      this.syncToDom();
1141      if (this._colliderDebugEnabled) {
1142        this.renderColliderDebugOverlays();
1143      }
1144    }
1145
1146    getCameraOffset() {
1147      return { x: this._cameraOffset.x, y: this._cameraOffset.y };
1148    }
1149
1150    _setSceneVisibility(sceneName) {
1151      if (sceneName === "cave") {
1152        $("#cave").css("display", "block");
1153        $("#map").css("display", "none");
1154        $("#tent1").css("display", "none");
1155      } else if (sceneName === "tent1") {
1156        $("#tent1").css("display", "block");
1157        $("#map").css("display", "none");
1158        $("#cave").css("display", "none");
1159        // Close timeline modal if open when leaving cave
1160        if (typeof closeTimelineModal === "function") {
1161          closeTimelineModal();
1162        }
1163      } else {
1164        $("#map").css("display", "block");
1165        $("#tent1").css("display", "none");
1166        $("#cave").css("display", "none");
1167        // Close timeline modal if open when leaving cave
1168        if (typeof closeTimelineModal === "function") {
1169          closeTimelineModal();
1170        }
1171        // Re-apply visibility state of collectible items based on inventory
1172        this._updateCollectibleVisibility();
1173      }
1174    }
1175
1176    /**
1177     * Update visibility of collectible items based on inventory state.
1178     * This ensures items that have been collected remain hidden when returning to the map.
1179     */
1180    _updateCollectibleVisibility() {
1181      if (typeof inventory === "undefined") return;
1182      
1183      // Fishing rod
1184      if (inventory.fishingRod) {
1185        $(".fishing-rod").css("display", "none");
1186      } else {
1187        $(".fishing-rod").css("display", "block");
1188      }
1189      
1190      // Matchbox
1191      if (inventory.matchbox) {
1192        $(".matchbox").css("display", "none");
1193      } else {
1194        $(".matchbox").css("display", "block");
1195      }
1196      
1197      // Resume/Paper
1198      if (inventory.resume) {
1199        $(".paper").css("display", "none");
1200      } else {
1201        $(".paper").css("display", "block");
1202      }
1203      
1204      // Minimap
1205      if (inventory.minimap) {
1206        $(".scroll").css("display", "none");
1207      } else {
1208        $(".scroll").css("display", "block");
1209      }
1210      
1211      // Axe (stump state)
1212      if (inventory.axe) {
1213        $("#stump").css("background-image", "url(" + (typeof URL !== "undefined" ? URL.getNature() : "") + "/stump.png)");
1214      } else {
1215        $("#stump").css("background-image", "url(" + (typeof URL !== "undefined" ? URL.getNature() : "") + "/stump-with-axe.png)");
1216      }
1217      
1218      // Wood log
1219      if (inventory.wood) {
1220        $("#wood-log").css("display", "none");
1221      }
1222      
1223      // Fishbook
1224      if (inventory.fishbook) {
1225        $(".book-item").css("display", "none");
1226      } else {
1227        $(".book-item").css("display", "block");
1228      }
1229      
1230      // Coins - hide collected coins
1231      if (inventory.coins && Array.isArray(inventory.coins)) {
1232        inventory.coins.forEach(function(coinId) {
1233          $(".coin-item[data-coin-id='" + coinId + "']").remove();
1234        });
1235      }
1236    }
1237
1238    _moveDylanToActiveScene() {
1239      var $dylan = $("#dylan");
1240      if (!$dylan.length) return;
1241
1242      var targetContainer;
1243      if (this.sceneName === "cave") {
1244        targetContainer = $("#cave");
1245      } else if (this.sceneName === "tent1") {
1246        targetContainer = $("#tent1");
1247      } else {
1248        targetContainer = $("#map");
1249      }
1250
1251      // Only move if Dylan is not already in the target container
1252      if (targetContainer.length && $dylan.parent()[0] !== targetContainer[0]) {
1253        targetContainer.append($dylan);
1254      }
1255      
1256      // Ensure Dylan is visible
1257      $dylan.css({
1258        "visibility": "visible",
1259        "display": "block"
1260      });
1261    }
1262
1263    _getSceneElement() {
1264      var selector =
1265        this.sceneName === "tent1"
1266          ? "#tent1"
1267          : this.sceneName === "cave"
1268          ? "#cave"
1269          : "#map";
1270      var $el = $(selector);
1271      if ($el.length) return $el;
1272      return $("#map");
1273    }
1274
1275    // Intro animation: make Dylan fall in from above the viewport
1276    _playIntroDrop() {
1277      var $dylan = $("#dylan");
1278      if (!$dylan.length) return;
1279      $dylan.removeClass("intro-drop");
1280      // Force reflow so animation can restart if needed
1281      void $dylan[0].offsetWidth;
1282      $dylan.addClass("intro-drop");
1283      // Show player immediately as drop starts
1284      $dylan.css("visibility", "visible");
1285      // Disable movement during intro
1286      if (global.playerController) {
1287        global.playerController.disableInput = true;
1288        global.playerController.keysDown = {};
1289        global.playerController.inputState = { x: 0, y: 0 };
1290      }
1291      if (this.player && typeof this.player.setVelocity === "function") {
1292        this.player.setVelocity({ x: 0, y: 0 });
1293      }
1294      // Clean up after animation finishes to avoid lingering transforms
1295      setTimeout(function () {
1296        $dylan.removeClass("intro-drop");
1297        if (global.playerController) {
1298          global.playerController.disableInput = false;
1299        }
1300      }, INTRO_DURATION_MS);
1301    }
1302
1303    // Utility: recenter camera on the player's current world position.
1304    // Clears manual pan offsets and lets syncToDom recompute from physics.
1305    centerCameraOnPlayer() {
1306      this._cameraOffset.x = 0;
1307      this._cameraOffset.y = 0;
1308      this.syncToDom();
1309      if (this._colliderDebugEnabled) {
1310        this.renderColliderDebugOverlays();
1311      }
1312    }
1313
1314    // Wire scene transitions from trigger definitions
1315    _wireSceneTriggers(cfg) {
1316      var self = this;
1317      if (!cfg || !cfg.triggers || !cfg.triggers.length) return;
1318      cfg.triggers.forEach(function (t) {
1319        if (!t.tag || !t.nextScene) return;
1320        var eventName = "enter:" + t.tag;
1321        self.collisionWorld.on(eventName, function () {
1322          self.changeScene(t.nextScene, {
1323            spawnOverride: t.nextSpawn || null
1324          });
1325        });
1326      });
1327    }
1328
1329    // ---- Sprite animation based on input direction ----
1330
1331    updateAnimation(deltaMs, input) {
1332      var dx = input && typeof input.x === "number" ? input.x : 0;
1333      var dy = input && typeof input.y === "number" ? input.y : 0;
1334
1335      var isMoving = dx !== 0 || dy !== 0;
1336      var dir = this._lastDir;
1337
1338      if (isMoving) {
1339        // Choose dominant axis for direction
1340        if (Math.abs(dx) > Math.abs(dy)) {
1341          dir = dx < 0 ? "left" : "right";
1342        } else {
1343          dir = dy < 0 ? "up" : "down";
1344        }
1345      }
1346
1347      // If direction changed, reset animation frame
1348      if (dir !== this._lastDir) {
1349        this._animFrame = 2;
1350        this._animAccum = 0;
1351        this._lastDir = dir;
1352      }
1353
1354      if (isMoving) {
1355        this._animAccum += deltaMs;
1356        var frameDuration = 90; // ms per frame (slightly snappier)
1357        if (this._animAccum >= frameDuration) {
1358          this._animAccum = 0;
1359          this._animFrame++;
1360          if (this._animFrame > 7) {
1361            this._animFrame = 2; // cycle frames 2..7
1362          }
1363        }
1364      } else {
1365        // Idle pose: stand on frame 2
1366        this._animFrame = 2;
1367      }
1368
1369      // Apply sprite frame if SpriteManager is available
1370      var frame = this._animFrame;
1371      var facing;
1372      switch (dir) {
1373        case "up":
1374          facing = "back";
1375          break;
1376        case "down":
1377          facing = "front";
1378          break;
1379        case "left":
1380          facing = "left";
1381          break;
1382        case "right":
1383          facing = "right";
1384          break;
1385      }
1386
1387      // Only update sprite when facing/frame actually change
1388      if (facing !== this._lastFacingApplied || frame !== this._lastFrameApplied) {
1389        this._lastFacingApplied = facing;
1390        this._lastFrameApplied = frame;
1391
1392        if (typeof SpriteManager !== "undefined") {
1393          SpriteManager.setSpriteFrame(facing, frame);
1394        } else {
1395          // Fallback: basic background-image updates if SpriteManager is unavailable
1396          var url =
1397            typeof URL !== "undefined" && URL.getDylan
1398              ? URL.getDylan()
1399              : "resources/images/characters/dylan";
1400          var suffix = "";
1401          if (facing === "front") suffix = "/dylan-front-";
1402          else if (facing === "back") suffix = "/dylan-back-";
1403          else if (facing === "left") suffix = "/dylan-left-";
1404          else if (facing === "right") suffix = "/dylan-right-";
1405          $("#dylan").css(
1406            "background-image",
1407            "url(" + url + suffix + frame + ".png)"
1408          );
1409        }
1410      }
1411    }
1412  }
1413
1414  // Build tent DOM on-demand (used when entering tent scene)
1415  function buildTentDomIfNeeded() {
1416    var $tent = $("#tent1");
1417    if (!$tent.length) return;
1418
1419    // Add all tent elements directly to #tent1 (like cave does)
1420    $tent.empty();
1421    
1422    // Add the main tentInterior background container
1423    $tent.append('<div class="tentInterior tent1"></div>');
1424    // Only add matchbox if it hasn't been collected yet
1425    if (typeof inventory !== "undefined" && !inventory.matchbox) {
1426      $tent.append('<a><div onClick="takeMatchbox()" class="matchbox"></div></a>');
1427    }
1428    $tent.append('<div class="divider"></div>');
1429    $tent.append('<div class="tentInterior tent1 door front"></div>');
1430    $tent.append('<div class="tentInterior tent1 backwall"></div>');
1431    $tent.append('<div class="tentInterior tent1 divider-wall"></div>');
1432    $tent.append('<div class="tentInterior tent1 divider-wall bottomLining"></div>');
1433    $tent.append('<div class="tentInterior tent1 divider-wall topLining"></div>');
1434    $tent.append('<div class="tentInterior tent1 divider-wall topLining t2"></div>');
1435    $tent.append('<div class="tentInterior tent1 bottomLining"></div>');
1436    $tent.append('<div class="tentInterior tent1 topLining"></div>');
1437    $tent.append('<div class="tentInterior tent1 topLining t2"></div>');
1438    
1439    // Kitchen
1440    $tent.append('<div class="shelf"></div>');
1441    $tent.append('<div class="kitchen"></div>');
1442    $tent.append('<div class="kitchen-floor"></div>');
1443    $tent.append('<div class="backdoor"></div>');
1444    $tent.append('<div class="doormat back"></div>');
1445    $tent.append('<div class="kitchen-table"></div>');
1446    
1447    // Center
1448    $tent.append('<a><div onClick="openPaintingLightbox()" class="painting"></div></a>');
1449    $tent.append('<div class="doormat front"></div>');
1450    
1451    // Den
1452    $tent.append('<a><div onClick="sitOnCouch()" class="couch"></div></a>');
1453    $tent.append('<a><div onClick="openTVScreen()" class="tv back"><div class="tv front"><p style="position: absolute; left: 13px; color: darkgray; font-size: 0.4rem; top: 10px;">About Me</p></div></div></a>');
1454  }
1455
1456  // Build cave DOM on-demand (used when entering cave scene)
1457  function buildCaveDomIfNeeded() {
1458    var $cave = $("#cave");
1459    if (!$cave.length) return;
1460    var $hall = $('<div class="cave-hall"></div>');
1461    var $sideRocksLeft = $('<div class="cave-side-rocks-left"></div>');
1462    var $sideRocksRight = $('<div class="cave-side-rocks-right"></div>');
1463    var $overlayLeft = $('<div class="cave-dark left"></div>');
1464    var $overlayRight = $('<div class="cave-dark right"></div>');
1465    var $exit = $('<div class="cave-exit" aria-label="Exit cave"></div>');
1466    var $exitLightBeam = $('<div class="cave-exit-light-beam"></div>');
1467    var $exitRocksLeft = $('<div class="cave-exit-rocks-left"></div>');
1468    var $exitRocksTop = $('<div class="cave-exit-rocks-top"></div>');
1469
1470    $cave.empty();
1471    $cave.append($overlayLeft);
1472    $cave.append($overlayRight);
1473    $cave.append($exitLightBeam);
1474    $cave.append($sideRocksLeft);
1475    $cave.append($sideRocksRight);
1476    $cave.append($hall);
1477    $cave.append($exitRocksLeft);
1478    $cave.append($exitRocksTop);
1479    $cave.append($exit);
1480
1481    if (typeof TIMELINE_ENTRIES !== "undefined") {
1482      // Create desktop timeline signs (absolute positioned)
1483      TIMELINE_ENTRIES.forEach(function (entry, idx) {
1484        var side = entry.side === "right" ? "right" : "left";
1485        var $sign = $('<div class="cave-sign ' + side + '"></div>');
1486        var year = entry.year || "";
1487        var title = entry.title || "";
1488        var desc = entry.description || "";
1489        var img = entry.image || "";
1490        
1491        if (img) {
1492          var $img = $('<div class="img"></div>');
1493          $img.css("background-image", "url(" + img + ")");
1494          $sign.append($img);
1495        }
1496        
1497        $sign.append("<div class='year'>" + year + "</div>");
1498        $sign.append("<div class='title'>" + title + "</div>");
1499        $sign.append("<div class='desc'>" + desc + "</div>");
1500        $sign.css("top", (entry.y || 0) + "px");
1501        $cave.append($sign);
1502        
1503        // Add interactive sign aligned with each timeline entry
1504        // Position on left or right side of bridge based on entry side
1505        var $interactiveSign = $('<div class="cave-timeline-sign-interact"></div>');
1506        // Cave hall is at left: 940px, width: 120px (center at 1000px)
1507        // Position signs closer to center, on the appropriate side
1508        var signX;
1509        if (side === "left") {
1510          signX = 940; // Left side of bridge
1511        } else {
1512          signX = 1028; // Right side of bridge, closer to center
1513        }
1514        // Align sign vertically with the timeline entry (same Y position, adjusted 4px up)
1515        var signY = (entry.y || 0) - 10;
1516        $interactiveSign.css({
1517          "left": signX + "px",
1518          "top": signY + "px",
1519          "z-index": "9998" // Default z-index (behind player's 9999)
1520        });
1521        $interactiveSign.attr("data-timeline-index", idx);
1522        $interactiveSign.attr("data-sign-y", signY); // Store Y position for z-index calculation
1523        
1524        $cave.append($interactiveSign);
1525      });
1526      
1527      // Create timeline modal
1528      var $timelineModal = $('<div class="timeline-modal"></div>');
1529      var $timelineModalOverlay = $('<div class="timeline-modal-overlay"></div>');
1530      var $timelineModalContent = $('<div class="timeline-modal-content"></div>');
1531      var $timelineModalClose = $('<a class="timeline-modal-close" onClick="closeTimelineModal()"><i class="nes-icon close"></i></a>');
1532      
1533      $timelineModalContent.append($timelineModalClose);
1534      $timelineModal.append($timelineModalOverlay);
1535      $timelineModal.append($timelineModalContent);
1536      $('body').append($timelineModal);
1537      
1538      // Add click handlers for interactive signs using event delegation
1539      // Use native addEventListener to avoid jQuery dependency issues
1540      document.addEventListener('click', function(e) {
1541        var target = e.target;
1542        var signElement = null;
1543        
1544        // Check if clicked element or its parent is the interactive sign
1545        while (target && target !== document.body) {
1546          if (target.classList && target.classList.contains('cave-timeline-sign-interact')) {
1547            signElement = target;
1548            break;
1549          }
1550          target = target.parentElement;
1551        }
1552        
1553        if (signElement) {
1554          var index = parseInt(signElement.getAttribute("data-timeline-index"));
1555          if (!isNaN(index) && typeof TIMELINE_ENTRIES !== "undefined" && TIMELINE_ENTRIES[index]) {
1556            if (typeof openTimelineModal === "function") {
1557              openTimelineModal(TIMELINE_ENTRIES[index]);
1558            }
1559          }
1560        }
1561      });
1562      
1563      // Close modal on overlay click
1564      var overlayElement = $timelineModalOverlay[0] || $timelineModalOverlay;
1565      if (overlayElement && overlayElement.addEventListener) {
1566        overlayElement.addEventListener('click', function() {
1567          if (typeof closeTimelineModal === "function") {
1568            closeTimelineModal();
1569          }
1570        });
1571      }
1572    }
1573  }
1574
1575  global.GameWorld = GameWorld;
1576})(window);
1577
1578

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.