From ffab3527cd19a2744a159cfdaea8885b04167b52 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 20 Feb 2026 19:58:39 +0000 Subject: [PATCH] WiP pathfinding --- public/break_escape/js/core/game.js | 2 +- public/break_escape/js/core/player.js | 251 +++++++++- public/break_escape/js/core/rooms.js | 2 +- .../break_escape/js/systems/npc-behavior.js | 2 +- .../js/systems/npc-pathfinding.js | 447 +++++++++++++++++- 5 files changed, 679 insertions(+), 25 deletions(-) diff --git a/public/break_escape/js/core/game.js b/public/break_escape/js/core/game.js index 4b3afd5..64810b6 100644 --- a/public/break_escape/js/core/game.js +++ b/public/break_escape/js/core/game.js @@ -1,5 +1,5 @@ import { initializeRooms, calculateWorldBounds, calculateRoomPositions, createRoom, revealRoom, updatePlayerRoom, rooms } from './rooms.js?v=17'; -import { createPlayer, updatePlayerMovement, movePlayerToPoint, facePlayerToward, player } from './player.js?v=8'; +import { createPlayer, updatePlayerMovement, movePlayerToPoint, facePlayerToward, player } from './player.js?v=16'; import { initializePathfinder } from './pathfinding.js?v=7'; import { initializeInventory, processInitialInventoryItems } from '../systems/inventory.js?v=9'; import { checkObjectInteractions, setGameInstance, isObjectInInteractionRange } from '../systems/interactions.js?v=31'; diff --git a/public/break_escape/js/core/player.js b/public/break_escape/js/core/player.js index 8b0a426..51f4424 100644 --- a/public/break_escape/js/core/player.js +++ b/public/break_escape/js/core/player.js @@ -7,7 +7,6 @@ import { RUN_SPEED_MULTIPLIER, RUN_ANIMATION_MULTIPLIER, ARRIVAL_THRESHOLD, - PLAYER_FEET_OFFSET_Y, ROOM_CHECK_THRESHOLD, CLICK_INDICATOR_SIZE, CLICK_INDICATOR_DURATION, @@ -35,6 +34,26 @@ let isKeyboardMoving = false; // Keyboard pause state (for when minigames need keyboard input) let keyboardPaused = false; +// Click-to-move pathfinding state +let playerPath = []; // Array of world {x, y} waypoints from EasyStar pathfinding +let playerPathIndex = 0; // Next waypoint index to head toward +let playerPathRequestId = 0; // Incremented on each click to discard stale async callbacks +let playerFollowingPath = false; // True when actively following an EasyStar route (skip physics collision-stop) +let playerFinalGoal = null; // Original click destination (world coords); cleared when we go direct or arrive +let pathDebugGraphics = null; // Phaser Graphics object used to draw the path overlay + +/** + * Returns the world position of the player's physics body centre (feet collider). + * All pathfinding and movement comparisons should use this rather than player.x/y + * (which is the sprite centre, not where the collision box sits). + */ +function playerBodyPos() { + if (player?.body) { + return { x: player.body.center.x, y: player.body.center.y }; + } + return { x: player.x, y: player.y }; +} + // Export functions to pause/resume keyboard interception export function pauseKeyboardInput() { keyboardPaused = true; @@ -726,6 +745,87 @@ function createLegacyPlayerAnimations(spriteSheet) { console.log(`βœ… Player legacy animations created for ${spriteSheet}`); } +/** + * Draw a fading visual overlay showing the raw EasyStar path (grey nodes) + * and the smoothed player path (cyan line + numbered circles). + * Clears any previous overlay automatically. + * + * @param {number} fromX - player start world X + * @param {number} fromY - player start world Y + * @param {Array} smoothed - smoothed waypoints [{x,y},...] + * @param {Array} [raw] - optional raw EasyStar waypoints for comparison + */ +function drawPathDebug(fromX, fromY, smoothed, raw) { + // Destroy previous overlay + if (pathDebugGraphics) { + pathDebugGraphics.destroy(); + pathDebugGraphics = null; + } + if (!gameRef || !smoothed || smoothed.length === 0) return; + + const g = gameRef.add.graphics(); + g.setDepth(900); // above most objects, below UI + pathDebugGraphics = g; + + // --- Raw path nodes (small grey circles) --- + if (raw && raw.length > 0) { + g.fillStyle(0x888888, 0.55); + for (const p of raw) { + g.fillCircle(p.x, p.y, 3); + } + } + + // Helper: all points including player origin + const allPoints = [{ x: fromX, y: fromY }, ...smoothed]; + + // --- Smoothed path line --- + g.lineStyle(2, 0x00ffff, 0.9); + g.beginPath(); + g.moveTo(allPoints[0].x, allPoints[0].y); + for (let i = 1; i < allPoints.length; i++) { + g.lineTo(allPoints[i].x, allPoints[i].y); + } + g.strokePath(); + + // --- Waypoint circles + index labels --- + for (let i = 0; i < smoothed.length; i++) { + const p = smoothed[i]; + // Outer ring + g.lineStyle(2, 0x00ffff, 1); + g.fillStyle(0x003333, 0.7); + g.fillCircle(p.x, p.y, 7); + g.strokeCircle(p.x, p.y, 7); + // Index text in scene (not part of graphics object) + const label = gameRef.add.text(p.x + 9, p.y - 7, String(i + 1), { + fontSize: '10px', color: '#00ffff', stroke: '#000000', strokeThickness: 2 + }).setDepth(901).setAlpha(0.9); + // Track labels so they can be cleared with the graphics + if (!g._debugLabels) g._debugLabels = []; + g._debugLabels.push(label); + } + + // --- Fade-out tween (3 s) --- + gameRef.tweens.add({ + targets: g, + alpha: { from: 1, to: 0 }, + duration: 3000, + ease: 'Linear', + onUpdate: () => { + // Keep labels in sync with graphics alpha + if (g._debugLabels) { + for (const lbl of g._debugLabels) lbl.setAlpha(g.alpha); + } + }, + onComplete: () => { + if (g._debugLabels) { + for (const lbl of g._debugLabels) lbl.destroy(); + } + g.destroy(); + if (pathDebugGraphics === g) pathDebugGraphics = null; + } + }); +} + export function movePlayerToPoint(x, y) { const worldBounds = gameRef.physics.world.bounds; @@ -736,8 +836,85 @@ export function movePlayerToPoint(x, y) { // Create click indicator createClickIndicator(x, y); - targetPoint = { x, y }; - isMoving = true; + // Reset path state and bump request ID to cancel any in-flight async callbacks + playerPath = []; + playerPathIndex = 0; + const requestId = ++playerPathRequestId; + + const pathfindingManager = window.pathfindingManager; + const roomId = window.currentPlayerRoom; + + if (pathfindingManager && roomId) { + // Use the body centre (feet collider) as the start position so all LOS + // and pathfinding queries are relative to what actually collides with walls. + const { x: px, y: py } = playerBodyPos(); + + const room = window.rooms?.[roomId]; + const boxCount = room?.wallCollisionBoxes?.length ?? 0; + console.log(`πŸ–±οΈ movePlayerToPoint: feet(${px.toFixed(0)},${py.toFixed(0)}) β†’ target(${x.toFixed(0)},${y.toFixed(0)}) room=${roomId} wallBoxes=${boxCount}`); + + // Prefer a direct route when line-of-sight is clear. + // Use physics-body LOS (checks actual wallCollisionBoxes) rather than + // the tile-grid approximation, so we don't walk into thin wall strips. + if (pathfindingManager.hasPhysicsLineOfSight(roomId, px, py, x, y)) { + console.log(' β†’ Direct LOS clear β€” going straight'); + drawPathDebug(px, py, [{ x, y }], null); + playerFollowingPath = false; + playerFinalGoal = null; + targetPoint = { x, y }; + isMoving = true; + } else { + // Snap the destination to the nearest walkable tile in case the click landed + // inside an obstacle (table, wall) β€” EasyStar cannot path to impassable tiles + const snappedDest = pathfindingManager.findNearestWalkableTile(roomId, x, y) || { x, y }; + if (snappedDest.x !== x || snappedDest.y !== y) { + console.log(` β†’ Dest snapped from (${x.toFixed(0)},${y.toFixed(0)}) to (${snappedDest.x.toFixed(0)},${snappedDest.y.toFixed(0)})`); + } + console.log(' β†’ LOS blocked β€” requesting EasyStar path...'); + + // Route around obstacles via EasyStar β€” result arrives asynchronously + pathfindingManager.findPath(roomId, px, py, snappedDest.x, snappedDest.y, (path) => { + // Ignore result if the player has already clicked somewhere else + if (requestId !== playerPathRequestId) return; + + if (path && path.length > 0) { + console.log(` β†’ EasyStar returned ${path.length} raw waypoints`); + + // Smooth using the body-centre position at callback time so that + // stale async results are still safe. + const { x: cx, y: cy } = playerBodyPos(); + const smoothed = pathfindingManager.smoothPathForPlayer( + roomId, cx, cy, path + ); + console.log(` β†’ Smoothed to ${smoothed.length} waypoints:`, + smoothed.map((p, i) => `[${i}](${p.x.toFixed(0)},${p.y.toFixed(0)})`).join(' β†’ ')); + + drawPathDebug(cx, cy, smoothed, path); + + playerFinalGoal = { x, y }; // remember original destination for LOS shortcutting + playerPath = smoothed; + playerPathIndex = 0; + playerFollowingPath = true; + // Start moving toward the first waypoint + targetPoint = playerPath[playerPathIndex++]; + isMoving = true; + } else { + console.warn(` ⚠️ EasyStar returned no path β€” falling back to straight line to snapped dest`); + const { x: cx, y: cy } = playerBodyPos(); + drawPathDebug(cx, cy, [snappedDest], null); + // No path found β€” fall back to straight-line toward snapped position + playerFollowingPath = false; + targetPoint = snappedDest; + isMoving = true; + } + }); + } + } else { + // Pathfinding not yet available β€” go direct + playerFollowingPath = false; + targetPoint = { x, y }; + isMoving = true; + } // Notify tutorial of movement if (window.getTutorialManager) { @@ -869,6 +1046,10 @@ function updatePlayerKeyboardMovement() { if (isMoving || targetPoint) { isMoving = false; targetPoint = null; + playerPath = []; + playerPathIndex = 0; + playerFollowingPath = false; + playerFinalGoal = null; } // Calculate movement direction based on keyboard input @@ -1066,21 +1247,51 @@ function updatePlayerMouseMovement() { return; } - // Cache player position - adjust for feet position - const px = player.x; - const py = player.y + PLAYER_FEET_OFFSET_Y; // Add offset to target the feet - - // Update player depth based on actual player position (not feet-adjusted) - updatePlayerDepth(px, player.y); - - // Use squared distance for performance + // Update depth every frame based on sprite position so layering is correct + // while the player walks along a click-to-move path. + updatePlayerDepth(player.x, player.y); + + // Use the body centre (feet collider) as the reference position. + // This matches what physically collides with walls, and is consistent with + // how the click destination was recorded (player should put their feet on the target). + const { x: px, y: py } = playerBodyPos(); + + // --- Direct-path shortcut --- + // While following a computed route, check every frame whether there is already + // a clear physics LOS to the FINAL destination. The moment there is, we ditch + // the remaining waypoints and head straight there, giving smooth arrival. + if (playerFollowingPath && playerFinalGoal) { + const pm = window.pathfindingManager; + const rid = window.currentPlayerRoom; + if (pm && rid && pm.hasPhysicsLineOfSight(rid, px, py, playerFinalGoal.x, playerFinalGoal.y)) { + targetPoint = playerFinalGoal; + playerFinalGoal = null; + playerPath = []; + playerPathIndex = 0; + playerFollowingPath = false; + // Debug: show the direct leg + if (window.pathfindingDebug) console.log(`βœ‚οΈ LOS shortcut to final goal (${targetPoint.x.toFixed(0)},${targetPoint.y.toFixed(0)})`); + } + } + // Distance from feet to current waypoint / final target const dx = targetPoint.x - px; - const dy = targetPoint.y - py; // Compare with feet position + const dy = targetPoint.y - py; const distanceSq = dx * dx + dy * dy; - // Reached target point + // Reached current waypoint / final target if (distanceSq < ARRIVAL_THRESHOLD * ARRIVAL_THRESHOLD) { + // If there are more path waypoints, advance to the next one without stopping + if (playerPathIndex < playerPath.length) { + targetPoint = playerPath[playerPathIndex++]; + return; + } + + // All waypoints exhausted β€” stop at the final destination isMoving = false; + playerPath = []; + playerPathIndex = 0; + playerFollowingPath = false; + playerFinalGoal = null; player.body.setVelocity(0, 0); if (player.isMoving) { player.isMoving = false; @@ -1097,8 +1308,8 @@ function updatePlayerMouseMovement() { } // Update last player position for depth calculations - lastPlayerPosition.x = px; - lastPlayerPosition.y = py - PLAYER_FEET_OFFSET_Y; // Store actual player position + lastPlayerPosition.x = player.x; + lastPlayerPosition.y = player.y; // Normalize movement vector for consistent speed const distance = Math.sqrt(distanceSq); @@ -1156,9 +1367,15 @@ function updatePlayerMouseMovement() { player.lastDirection = player.direction; } - // Stop if collision detected - if (player.body.blocked.none === false) { + // Stop if collision detected β€” but only for straight-line (non-pathfinded) movement. + // When following a computed route, trust the waypoints to navigate around obstacles; + // stopping here would cancel a valid path just from grazing a tile corner. + if (!playerFollowingPath && player.body.blocked.none === false) { isMoving = false; + playerPath = []; + playerPathIndex = 0; + playerFollowingPath = false; + playerFinalGoal = null; player.body.setVelocity(0, 0); player.isMoving = false; diff --git a/public/break_escape/js/core/rooms.js b/public/break_escape/js/core/rooms.js index c6c0838..4c5e894 100644 --- a/public/break_escape/js/core/rooms.js +++ b/public/break_escape/js/core/rooms.js @@ -60,7 +60,7 @@ import { initializeDoors, createDoorSpritesForRoom, checkDoorTransitions, update import { initializeObjectPhysics, setupChairCollisions, setupExistingChairsWithNewRoom, calculateChairSpinDirection, updateSwivelChairRotation, updateSpriteDepth } from '../systems/object-physics.js'; import { initializePlayerEffects, createPlayerBumpEffect, createPlantBumpEffect } from '../systems/player-effects.js'; import { initializeCollision, createWallCollisionBoxes, removeTilesUnderDoor, removeWallTilesForDoorInRoom, removeWallTilesAtWorldPosition } from '../systems/collision.js'; -import { NPCPathfindingManager } from '../systems/npc-pathfinding.js?v=2'; +import { NPCPathfindingManager } from '../systems/npc-pathfinding.js?v=10'; import NPCSpriteManager from '../systems/npc-sprites.js?v=3'; export let rooms = {}; diff --git a/public/break_escape/js/systems/npc-behavior.js b/public/break_escape/js/systems/npc-behavior.js index b042d72..947ac80 100644 --- a/public/break_escape/js/systems/npc-behavior.js +++ b/public/break_escape/js/systems/npc-behavior.js @@ -22,7 +22,7 @@ */ import { TILE_SIZE } from '../utils/constants.js?v=8'; -import { NPCPathfindingManager } from './npc-pathfinding.js?v=2'; +import { NPCPathfindingManager } from './npc-pathfinding.js?v=10'; /** * NPCBehaviorManager - Manages all NPC behaviors diff --git a/public/break_escape/js/systems/npc-pathfinding.js b/public/break_escape/js/systems/npc-pathfinding.js index 895607c..bc6fd43 100644 --- a/public/break_escape/js/systems/npc-pathfinding.js +++ b/public/break_escape/js/systems/npc-pathfinding.js @@ -18,6 +18,15 @@ import { TILE_SIZE, GRID_SIZE } from '../utils/constants.js?v=8'; const PATROL_EDGE_OFFSET = 2; // Distance from room edge (2 tiles) +/** + * Set window.pathfindingDebug = true in the browser console to enable + * verbose per-check logging for player pathfinding. + * Automatically respects the existing visualDebugMode toggle (backtick key). + */ +function isDebug() { + return !!window.pathfindingDebug; +} + /** * NPCPathfindingManager - Manages pathfinding for all NPCs across all rooms */ @@ -91,6 +100,15 @@ export class NPCPathfindingManager { console.log(`βœ… Pathfinding initialized for room ${roomId}`); console.log(` Grid: ${mapWidth}x${mapHeight} tiles | Patrol bounds: (${bounds.x}, ${bounds.y}) to (${bounds.x + bounds.width}, ${bounds.y + bounds.height})`); + // NOTE: refreshGridFromPhysicsBodies() is intentionally NOT called here. + // buildGridFromWalls() already marks wall tiles + table objects correctly. + // The physics wall-strip bodies are thin 8px edges that straddle tile + // boundaries; converting them to tile ranges produces false positives that + // block walkable tiles. hasPhysicsLineOfSight() handles exact geometry + // for LOS checks where it really matters. + // Call window.refreshPathfindingGrid() from the console if you want to + // overlay physics bodies onto the grid manually for debugging. + } catch (error) { console.error(`❌ Failed to initialize pathfinding for room ${roomId}:`, error); console.error('Error stack:', error.stack); @@ -329,11 +347,13 @@ export class NPCPathfindingManager { return false; // End position outside room } - // Convert world coordinates to tile coordinates (use center of tiles) - const startTileX = Math.round((startX - bounds.worldX) / TILE_SIZE); - const startTileY = Math.round((startY - bounds.worldY) / TILE_SIZE); - const endTileX = Math.round((endX - bounds.worldX) / TILE_SIZE); - const endTileY = Math.round((endY - bounds.worldY) / TILE_SIZE); + // Convert world coordinates to tile coordinates. + // Use Math.floor (consistent with buildGridFromWalls and findPath) so that + // positions near tile boundaries map to the same tile the grid uses. + const startTileX = Math.floor((startX - bounds.worldX) / TILE_SIZE); + const startTileY = Math.floor((startY - bounds.worldY) / TILE_SIZE); + const endTileX = Math.floor((endX - bounds.worldX) / TILE_SIZE); + const endTileY = Math.floor((endY - bounds.worldY) / TILE_SIZE); // Helper function to check if a tile is walkable const isTileWalkable = (tx, ty) => { @@ -512,7 +532,424 @@ export class NPCPathfindingManager { console.log(`βœ… Marked ${markedCount} door tiles as walkable in ${roomId} at (${tileX}, ${tileY}) direction: ${direction}`); } + + /** + * Update the pathfinding grid by marking any tile that overlaps a real + * Phaser physics body in room.wallCollisionBoxes as impassable. + * + * Call this after createWallCollisionBoxes() finishes for the room so the + * EasyStar grid matches the actual physics geometry (thin wall strips at tile + * edges that may only partially cover border tiles). + * + * @param {string} roomId + * @returns {number} Number of newly-blocked tiles (0 if nothing to do) + */ + refreshGridFromPhysicsBodies(roomId) { + const grid = this.grids.get(roomId); + const pathfinder = this.pathfinders.get(roomId); + const bounds = this.roomBounds.get(roomId); + const room = window.rooms?.[roomId]; + + if (!grid || !pathfinder || !bounds || !room?.wallCollisionBoxes?.length) { + return 0; + } + + let marked = 0; + for (const box of room.wallCollisionBoxes) { + if (!box?.body) continue; + + // Convert the physics AABB to tile ranges (any tile the body overlaps) + const tileX1 = Math.floor((box.body.left - bounds.worldX) / TILE_SIZE); + const tileY1 = Math.floor((box.body.top - bounds.worldY) / TILE_SIZE); + const tileX2 = Math.floor((box.body.right - bounds.worldX) / TILE_SIZE); + const tileY2 = Math.floor((box.body.bottom - bounds.worldY) / TILE_SIZE); + + for (let ty = tileY1; ty <= tileY2; ty++) { + for (let tx = tileX1; tx <= tileX2; tx++) { + if (ty >= 0 && ty < grid.length && tx >= 0 && tx < grid[0].length) { + if (grid[ty][tx] === 0) { + grid[ty][tx] = 1; // block + marked++; + } + } + } + } + } + + if (marked > 0) { + pathfinder.setGrid(grid); + console.log(`βœ… refreshGridFromPhysicsBodies [${roomId}]: marked ${marked} additional tiles from ${room.wallCollisionBoxes.length} physics bodies`); + } + return marked; + } + + /** + * Draw a colour-coded tile overlay for the pathfinding grid. + * + * Red = impassable (value 1) + * Green = walkable (value 0, faint) + * + * @param {string} roomId - Room to visualise + * @param {Phaser.Scene} scene - Active Phaser scene (for graphics factory) + * @returns {Phaser.GameObjects.Graphics|null} + */ + drawGridDebug(roomId, scene) { + const grid = this.grids.get(roomId); + const bounds = this.roomBounds.get(roomId); + if (!grid || !bounds || !scene) { + console.warn(`⚠️ drawGridDebug: missing grid/bounds/scene for room "${roomId}"`); + return null; + } + + this.clearGridDebug(); + + const g = scene.add.graphics(); + g.setDepth(850); // Above game objects, below path overlay + this._gridDebugGraphics = g; + + const rows = grid.length; + const cols = grid[0].length; + + for (let ty = 0; ty < rows; ty++) { + for (let tx = 0; tx < cols; tx++) { + const wx = bounds.worldX + tx * TILE_SIZE; + const wy = bounds.worldY + ty * TILE_SIZE; + if (grid[ty][tx] === 1) { + // Impassable β€” semi-transparent red + g.fillStyle(0xff2222, 0.40); + g.fillRect(wx + 1, wy + 1, TILE_SIZE - 2, TILE_SIZE - 2); + } else { + // Walkable β€” very faint green + g.fillStyle(0x22ff44, 0.10); + g.fillRect(wx + 1, wy + 1, TILE_SIZE - 2, TILE_SIZE - 2); + } + } + } + + console.log(`πŸ—ΊοΈ Grid debug drawn for room "${roomId}": ${cols}Γ—${rows} tiles`); + return g; + } + + /** + * Remove the grid debug overlay created by drawGridDebug(). + */ + clearGridDebug() { + if (this._gridDebugGraphics) { + this._gridDebugGraphics.destroy(); + this._gridDebugGraphics = null; + } + } + + /** + * Smooth a raw EasyStar path using greedy string-pulling. + * + * Scans from the end of the remaining path to find the furthest waypoint + * reachable by clear line-of-sight from the current position, then jumps + * there. This collapses redundant tile-by-tile steps into direct segments, + * eliminating zigzagging and ensuring every kept waypoint is actually + * unobstructed from the previous one. + * + * @param {string} roomId - Room identifier + * @param {number} startX - World X of the starting position (usually current player pos) + * @param {number} startY - World Y of the starting position + * @param {Array} path - Raw world-coordinate waypoint array from findPath() + * @returns {Array} Smoothed waypoint array (may equal path if already optimal) + */ + smoothPath(roomId, startX, startY, path) { + if (!path || path.length <= 1) return path; + + const smoothed = []; + let cx = startX; + let cy = startY; + let i = 0; + + while (i < path.length) { + // Scan backwards from the end to find the farthest directly-visible waypoint + let farthest = i; // default: just step to the very next waypoint + for (let j = path.length - 1; j > i; j--) { + if (this.hasLineOfSight(roomId, cx, cy, path[j].x, path[j].y)) { + farthest = j; + break; + } + } + + smoothed.push(path[farthest]); + cx = path[farthest].x; + cy = path[farthest].y; + i = farthest + 1; + } + + return smoothed.length > 0 ? smoothed : path; + } + + /** + * Find the nearest walkable tile to a world position using a spiral search. + * Useful when a click target lands inside an obstacle tile. + * + * @param {string} roomId - Room identifier + * @param {number} worldX - Desired world X + * @param {number} worldY - Desired world Y + * @param {number} [maxRadius=4] - Maximum tile radius to search + * @returns {{x: number, y: number}|null} - World coords of nearest walkable tile centre, or null + */ + findNearestWalkableTile(roomId, worldX, worldY, maxRadius = 4) { + const grid = this.grids.get(roomId); + const bounds = this.roomBounds.get(roomId); + if (!grid || !bounds) return null; + + const centerTileX = Math.floor((worldX - bounds.worldX) / TILE_SIZE); + const centerTileY = Math.floor((worldY - bounds.worldY) / TILE_SIZE); + + // Already walkable β€” return center of that tile + if (centerTileY >= 0 && centerTileY < grid.length && + centerTileX >= 0 && centerTileX < grid[0].length && + grid[centerTileY][centerTileX] === 0) { + return { + x: bounds.worldX + centerTileX * TILE_SIZE + TILE_SIZE / 2, + y: bounds.worldY + centerTileY * TILE_SIZE + TILE_SIZE / 2 + }; + } + + // Spiral outward, collecting ALL candidates within maxRadius, then return + // the one closest by Euclidean distance to the original world position. + // (The old code returned the first found in scan order, which could be a + // tile diagonally opposite to the actual nearest walkable tile.) + let best = null; + let bestDistSq = Infinity; + + for (let r = 1; r <= maxRadius; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + // Only check the ring at radius r (not inner tiles already checked) + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; + + const tx = centerTileX + dx; + const ty = centerTileY + dy; + + if (ty < 0 || ty >= grid.length || tx < 0 || tx >= grid[0].length) continue; + if (grid[ty][tx] !== 0) continue; + + const cx = bounds.worldX + tx * TILE_SIZE + TILE_SIZE / 2; + const cy = bounds.worldY + ty * TILE_SIZE + TILE_SIZE / 2; + const ddx = cx - worldX; + const ddy = cy - worldY; + const distSq = ddx * ddx + ddy * ddy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = { x: cx, y: cy }; + } + } + } + // Early exit: once we've scanned the whole ring at r and found a candidate, + // any tile at r+1 will be farther away, so stop. + if (best !== null) break; + } + + return best; + } + + /** + * Check LOS against the actual Phaser physics bodies in room.wallCollisionBoxes. + * + * Performs a "fat segment" sweep: three parallel rays are tested β€” the centre + * line plus two offset by Β±bodyHalfWidth perpendicular to the travel direction. + * This accurately represents whether the full width of the player's collision + * box can travel the segment without touching a wall body. + * + * Falls back to tile-grid LOS when no collision boxes are loaded yet. + * + * @param {string} roomId + * @param {number} x1 - start world X (player body centre) + * @param {number} y1 - start world Y + * @param {number} x2 - end world X + * @param {number} y2 - end world Y + * @param {number} [bodyMargin=2] - extra clearance in px around each AABB (small β€” body width handles the rest) + * @param {number} [bodyHalfWidth=9] - half the player collision box width (atlas body = 18px β†’ 9px) + * @returns {boolean} + */ + hasPhysicsLineOfSight(roomId, x1, y1, x2, y2, bodyMargin = 2, bodyHalfWidth = 9) { + const room = window.rooms?.[roomId]; + if (!room || !room.wallCollisionBoxes || room.wallCollisionBoxes.length === 0) { + if (isDebug()) { + const reason = !room ? 'no room data' : 'no wallCollisionBoxes'; + console.log(`πŸ” physLOS (${x1.toFixed(0)},${y1.toFixed(0)})β†’(${x2.toFixed(0)},${y2.toFixed(0)}): falling back to tile grid (${reason})`); + } + return this.hasLineOfSight(roomId, x1, y1, x2, y2); + } + + // Compute perpendicular unit vector to the travel direction. + // Used to offset the two side rays by Β±bodyHalfWidth. + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + // perpendicular is (-dy, dx) normalised; if segment is zero-length use (1,0) + const px = len > 0.001 ? -dy / len : 1; + const py = len > 0.001 ? dx / len : 0; + + // Three parallel rays: centre, left edge, right edge + const rays = [ + { ax: x1, ay: y1, bx: x2, by: y2 }, + { ax: x1 + px * bodyHalfWidth, ay: y1 + py * bodyHalfWidth, bx: x2 + px * bodyHalfWidth, by: y2 + py * bodyHalfWidth }, + { ax: x1 - px * bodyHalfWidth, ay: y1 - py * bodyHalfWidth, bx: x2 - px * bodyHalfWidth, by: y2 - py * bodyHalfWidth }, + ]; + + const boxes = room.wallCollisionBoxes; + for (const box of boxes) { + if (!box?.body) continue; + const left = box.body.left - bodyMargin; + const right = box.body.right + bodyMargin; + const top = box.body.top - bodyMargin; + const bottom = box.body.bottom + bodyMargin; + + for (const ray of rays) { + if (_segmentIntersectsAABB(ray.ax, ray.ay, ray.bx, ray.by, left, top, right, bottom)) { + if (isDebug()) { + console.log(`🧱 physLOS BLOCKED: box(${left.toFixed(0)},${top.toFixed(0)})β†’(${right.toFixed(0)},${bottom.toFixed(0)}) hit by ray (${ray.ax.toFixed(0)},${ray.ay.toFixed(0)})β†’(${ray.bx.toFixed(0)},${ray.by.toFixed(0)})`); + } + return false; + } + } + } + + if (isDebug()) { + console.log(`βœ… physLOS CLEAR (3-ray fat sweep, halfWidth=${bodyHalfWidth})`); + } + + // Physics bodies are the authoritative source for what blocks the player. + // EasyStar still uses the tile grid for routing so paths avoid wall-face tiles + // and table objects correctly. + return true; + } + + /** + * Like smoothPath but uses hasPhysicsLineOfSight for each segment test. + * Should be used for player movement where exact wall geometry matters. + * + * @param {string} roomId + * @param {number} startX - current player world X + * @param {number} startY - current player world Y + * @param {Array} path - raw world-coordinate waypoint array + * @returns {Array} smoothed waypoints + */ + smoothPathForPlayer(roomId, startX, startY, path) { + if (!path || path.length <= 1) return path; + + const debug = isDebug(); + if (debug) { + console.group(`πŸ—ΊοΈ smoothPathForPlayer: ${path.length} raw waypoints from (${startX.toFixed(0)},${startY.toFixed(0)})`); + console.log('Raw waypoints:', path.map((p, i) => `[${i}](${p.x.toFixed(0)},${p.y.toFixed(0)})`).join(' β†’ ')); + } + + const smoothed = []; + let cx = startX; + let cy = startY; + let i = 0; + let step = 0; + + while (i < path.length) { + let farthest = i; + for (let j = path.length - 1; j > i; j--) { + if (this.hasPhysicsLineOfSight(roomId, cx, cy, path[j].x, path[j].y)) { + farthest = j; + break; + } + } + if (debug) { + const skipped = farthest - i; + const kept = path[farthest]; + console.log(` step ${step}: from (${cx.toFixed(0)},${cy.toFixed(0)}) β†’ waypoint[${farthest}] (${kept.x.toFixed(0)},${kept.y.toFixed(0)})${skipped > 0 ? ` [skipped ${skipped}]` : ' [no skip]'}`); + } + smoothed.push(path[farthest]); + cx = path[farthest].x; + cy = path[farthest].y; + i = farthest + 1; + step++; + } + + if (debug) { + console.log(`βœ… Smoothed: ${path.length} β†’ ${smoothed.length} waypoints`); + console.log('Smoothed waypoints:', smoothed.map((p, i) => `[${i}](${p.x.toFixed(0)},${p.y.toFixed(0)})`).join(' β†’ ')); + console.groupEnd(); + } + + return smoothed.length > 0 ? smoothed : path; + } +} + +/** + * Segment vs AABB intersection test (slab method). + * Returns true if the segment from (x1,y1)β†’(x2,y2) passes through the + * rectangle defined by [left,right] Γ— [top,bottom]. + * + * @private + */ +function _segmentIntersectsAABB(x1, y1, x2, y2, left, top, right, bottom) { + const dx = x2 - x1; + const dy = y2 - y1; + let tmin = 0; + let tmax = 1; + + // X slab + if (Math.abs(dx) < 1e-9) { + if (x1 < left || x1 > right) return false; + } else { + const tx1 = (left - x1) / dx; + const tx2 = (right - x1) / dx; + tmin = Math.max(tmin, Math.min(tx1, tx2)); + tmax = Math.min(tmax, Math.max(tx1, tx2)); + if (tmin > tmax) return false; + } + + // Y slab + if (Math.abs(dy) < 1e-9) { + if (y1 < top || y1 > bottom) return false; + } else { + const ty1 = (top - y1) / dy; + const ty2 = (bottom - y1) / dy; + tmin = Math.max(tmin, Math.min(ty1, ty2)); + tmax = Math.min(tmax, Math.max(ty1, ty2)); + if (tmin > tmax) return false; + } + + return true; } // Export as global for easy access window.NPCPathfindingManager = NPCPathfindingManager; + +/** + * Console helpers β€” call these from the browser console to visualise the + * pathfinding grid for the room the player is currently in. + * + * Usage: + * window.showPathfindingGrid() // draw grid overlay + * window.hidePathfindingGrid() // remove it + * window.refreshPathfindingGrid() // rebake physics bodies then redraw + */ +window.showPathfindingGrid = () => { + const pm = window.pathfindingManager; + const rid = window.currentPlayerRoom; + // The main game scene is always scenes[0]; the .find() approach fails because + // Phaser marks a scene as inactive during the same frame it is running. + const scene = window.game?.scene?.scenes?.[0]; + if (!pm || !rid || !scene) { + console.warn('showPathfindingGrid: pathfindingManager / currentPlayerRoom / scene not ready', + { pm: !!pm, rid, scene: !!scene }); + return; + } + pm.drawGridDebug(rid, scene); + console.log(`πŸ—ΊοΈ Grid overlay shown for room "${rid}" β€” red=blocked, green=walkable`); +}; + +window.hidePathfindingGrid = () => { + window.pathfindingManager?.clearGridDebug(); +}; + +window.refreshPathfindingGrid = () => { + const pm = window.pathfindingManager; + const rid = window.currentPlayerRoom; + if (!pm || !rid) { console.warn('refreshPathfindingGrid: not ready'); return; } + const n = pm.refreshGridFromPhysicsBodies(rid); + console.log(`refreshPathfindingGrid: baked ${n} tiles from physics bodies in room "${rid}"`); + window.showPathfindingGrid(); +};