From f1ac7ee3833f2b89153da400a06f76179fd7f186 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Sun, 15 Feb 2026 23:14:13 +0000 Subject: [PATCH] feat: Enhance NPC pathfinding and line of sight checks for improved chase behavior across rooms --- .../break_escape/js/systems/npc-behavior.js | 207 +++++++++++++++++- .../js/systems/npc-pathfinding.js | 140 +++++++++++- 2 files changed, 338 insertions(+), 9 deletions(-) diff --git a/public/break_escape/js/systems/npc-behavior.js b/public/break_escape/js/systems/npc-behavior.js index 54462aa..8101d6c 100644 --- a/public/break_escape/js/systems/npc-behavior.js +++ b/public/break_escape/js/systems/npc-behavior.js @@ -165,6 +165,13 @@ class NPCBehavior { this.collisionRotationAngle = 0; // Clockwise rotation angle when blocked (0-360) this.wasBlockedLastFrame = false; // Track block state for smooth transitions + // Chase state (for hostile NPCs) + this.chasePath = []; // Path to player when chasing + this.chasePathIndex = 0; // Current position in chase path + this.lastChasePathRequest = 0; // Timestamp of last pathfinding request + this.chasePathUpdateInterval = 500; // Recalculate path every 500ms + this.lastPlayerPosition = null; // Track player position for path recalculation + // Personal space state this.backingAway = false; @@ -462,6 +469,10 @@ class NPCBehavior { return; } + // Update NPC's current room if they've moved to a different room + // This allows hostile NPCs to chase across rooms with correct pathfinding + this.updateCurrentRoom(); + // Check if NPC is stuck in a wall and needs to escape this.checkAndEscapeWall(time); @@ -484,6 +495,40 @@ class NPCBehavior { } } + /** + * Update NPC's current room based on their position + * This allows NPCs to chase across rooms and use correct pathfinding data + */ + updateCurrentRoom() { + const pathfindingManager = this.pathfindingManager || window.pathfindingManager; + if (!pathfindingManager) return; + + const npcX = this.sprite.x; + const npcY = this.sprite.y; + + // Check all loaded rooms to see which one contains the NPC + const roomBounds = pathfindingManager.roomBounds; + for (const [roomId, bounds] of roomBounds) { + const roomMinX = bounds.worldX; + const roomMinY = bounds.worldY; + const roomMaxX = bounds.worldX + (bounds.mapWidth * 32); + const roomMaxY = bounds.worldY + (bounds.mapHeight * 32); + + // Check if NPC is within this room's bounds + if (npcX >= roomMinX && npcX <= roomMaxX && npcY >= roomMinY && npcY <= roomMaxY) { + if (this.roomId !== roomId) { + console.log(`🚪 [${this.npcId}] Moved from ${this.roomId} to ${roomId}`); + this.roomId = roomId; + + // Clear chase path since we're in a new room + this.chasePath = []; + this.chasePathIndex = 0; + } + return; // Found the room, stop checking + } + } + } + determineState(playerPos) { if (!playerPos) { return 'idle'; @@ -1008,6 +1053,9 @@ class NPCBehavior { updateHostileBehavior(playerPos, delta) { if (!playerPos) return false; + const pathfindingManager = this.pathfindingManager || window.pathfindingManager; + const chaseSpeed = this.config.hostile.chaseSpeed || 120; + // Calculate distance to player const dx = playerPos.x - this.sprite.x; const dy = playerPos.y - this.sprite.y; @@ -1019,9 +1067,11 @@ class NPCBehavior { // If in attack range, try to attack if (distance <= attackRange) { - // Stop moving + // Stop moving and clear chase path this.sprite.body.setVelocity(0, 0); this.isMoving = false; + this.chasePath = []; + this.chasePathIndex = 0; // Face player this.direction = this.calculateDirection(dx, dy); @@ -1035,20 +1085,119 @@ class NPCBehavior { return true; } - // Chase player - move towards them - const chaseSpeed = this.config.hostile.chaseSpeed || 120; + // Chase player - check line of sight only if in same room + let hasLineOfSight = false; + if (pathfindingManager) { + // Determine which room the NPC is currently in + // (Use stored roomId - NPCs track their room assignment) + const npcRoomBounds = pathfindingManager.getBounds(this.roomId); + const playerRoom = window.player?.currentRoom; - // Calculate normalized direction + // Only use direct line of sight if NPC and player are in the same room + // This prevents NPCs from thinking they can move directly through walls between rooms + if (playerRoom === this.roomId && npcRoomBounds) { + const roomMinX = npcRoomBounds.worldX; + const roomMinY = npcRoomBounds.worldY; + const roomMaxX = npcRoomBounds.worldX + (npcRoomBounds.mapWidth * 32); + const roomMaxY = npcRoomBounds.worldY + (npcRoomBounds.mapHeight * 32); + + // Verify both positions are within this room's bounds + const npcInBounds = this.sprite.x >= roomMinX && this.sprite.x <= roomMaxX && + this.sprite.y >= roomMinY && this.sprite.y <= roomMaxY; + const playerInBounds = playerPos.x >= roomMinX && playerPos.x <= roomMaxX && + playerPos.y >= roomMinY && playerPos.y <= roomMaxY; + + if (npcInBounds && playerInBounds) { + hasLineOfSight = pathfindingManager.hasLineOfSight( + this.roomId, + this.sprite.x, + this.sprite.y, + playerPos.x, + playerPos.y + ); + } + } + } + + if (hasLineOfSight) { + // Clear path - move directly toward player (diagonal movement works fine) + // Clear any existing pathfinding path since we don't need it + this.chasePath = []; + this.chasePathIndex = 0; + + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + + this.sprite.body.setVelocity( + normalizedDx * chaseSpeed, + normalizedDy * chaseSpeed + ); + + this.direction = this.calculateDirection(dx, dy); + this.playAnimation('walk', this.direction); + this.isMoving = true; + + return true; + } + + // No line of sight - use pathfinding to route around obstacles + const currentTime = Date.now(); + + // Check if we need to recalculate path + const needsNewPath = + this.chasePath.length === 0 || // No path yet + this.chasePathIndex >= this.chasePath.length || // Reached end of path + currentTime - this.lastChasePathRequest > this.chasePathUpdateInterval || // Path outdated + (this.lastPlayerPosition && // Player moved significantly + Math.abs(playerPos.x - this.lastPlayerPosition.x) > 64 || + Math.abs(playerPos.y - this.lastPlayerPosition.y) > 64); + + if (needsNewPath) { + this.requestChasePath(playerPos, currentTime); + } + + // Follow chase path if available + if (this.chasePath.length > 0 && this.chasePathIndex < this.chasePath.length) { + const nextWaypoint = this.chasePath[this.chasePathIndex]; + const waypointDx = nextWaypoint.x - this.sprite.x; + const waypointDy = nextWaypoint.y - this.sprite.y; + const waypointDistance = Math.sqrt(waypointDx * waypointDx + waypointDy * waypointDy); + + // Reached current waypoint? Move to next + if (waypointDistance < 8) { + this.chasePathIndex++; + + // If we reached the end of the path, request a new one next frame + if (this.chasePathIndex >= this.chasePath.length) { + this.chasePath = []; + this.chasePathIndex = 0; + } + return true; + } + + // Move toward current waypoint + const velocityX = (waypointDx / waypointDistance) * chaseSpeed; + const velocityY = (waypointDy / waypointDistance) * chaseSpeed; + this.sprite.body.setVelocity(velocityX, velocityY); + + // Calculate and update direction + this.direction = this.calculateDirection(waypointDx, waypointDy); + this.playAnimation('walk', this.direction); + this.isMoving = true; + + return true; + } + + // No path available yet - move directly towards player as fallback + // (This handles the initial frame before pathfinding completes) const normalizedDx = dx / distance; const normalizedDy = dy / distance; - // Set velocity towards player this.sprite.body.setVelocity( normalizedDx * chaseSpeed, normalizedDy * chaseSpeed ); - // Calculate and update direction this.direction = this.calculateDirection(dx, dy); this.playAnimation('walk', this.direction); this.isMoving = true; @@ -1056,6 +1205,52 @@ class NPCBehavior { return true; } + /** + * Request a new pathfinding calculation to chase the player + * Uses EasyStar.js pathfinding to route around obstacles + * NPCs can chase across rooms - pathfinding validates positions are reachable + */ + requestChasePath(playerPos, currentTime) { + this.lastChasePathRequest = currentTime; + this.lastPlayerPosition = { x: playerPos.x, y: playerPos.y }; + + const pathfindingManager = this.pathfindingManager || window.pathfindingManager; + if (!pathfindingManager) { + console.warn(`⚠️ No pathfinding manager for hostile ${this.npcId}`); + return; + } + + // Verify the NPC's room has pathfinding initialized + const roomBounds = pathfindingManager.getBounds(this.roomId); + if (!roomBounds) { + console.warn(`⚠️ Room ${this.roomId} not loaded or pathfinding not initialized for ${this.npcId}`); + this.chasePath = []; + this.chasePathIndex = 0; + return; + } + + // Request path from current position to player position + pathfindingManager.findPath( + this.roomId, + this.sprite.x, + this.sprite.y, + playerPos.x, + playerPos.y, + (path) => { + if (path && path.length > 0) { + this.chasePath = path; + this.chasePathIndex = 0; + // console.log(`🔴 [${this.npcId}] Chase path calculated with ${path.length} waypoints`); + } else { + // No path found - target is unreachable or out of room + this.chasePath = []; + this.chasePathIndex = 0; + // console.warn(`⚠️ [${this.npcId}] No chase path to player`); + } + } + ); + } + calculateDirection(dx, dy) { const absVX = Math.abs(dx); const absVY = Math.abs(dy); diff --git a/public/break_escape/js/systems/npc-pathfinding.js b/public/break_escape/js/systems/npc-pathfinding.js index f82810f..9d21b65 100644 --- a/public/break_escape/js/systems/npc-pathfinding.js +++ b/public/break_escape/js/systems/npc-pathfinding.js @@ -219,7 +219,29 @@ export class NPCPathfindingManager { const bounds = this.roomBounds.get(roomId); if (!pathfinder || !bounds) { - console.warn(`⚠️ No pathfinder for room ${roomId}`); + console.warn(`⚠️ No pathfinder for room ${roomId} - room may not be loaded`); + callback(null); + return; + } + + // Validate that start and end positions are within reasonable range of room bounds + const roomWorldMinX = bounds.worldX; + const roomWorldMinY = bounds.worldY; + const roomWorldMaxX = bounds.worldX + (bounds.mapWidth * TILE_SIZE); + const roomWorldMaxY = bounds.worldY + (bounds.mapHeight * TILE_SIZE); + + // Check if start position is in this room + if (startX < roomWorldMinX - TILE_SIZE || startX > roomWorldMaxX + TILE_SIZE || + startY < roomWorldMinY - TILE_SIZE || startY > roomWorldMaxY + TILE_SIZE) { + console.warn(`⚠️ Start position (${startX}, ${startY}) is outside room ${roomId} bounds`); + callback(null); + return; + } + + // Check if end position is in this room + if (endX < roomWorldMinX - TILE_SIZE || endX > roomWorldMaxX + TILE_SIZE || + endY < roomWorldMinY - TILE_SIZE || endY > roomWorldMaxY + TILE_SIZE) { + console.warn(`⚠️ End position (${endX}, ${endY}) is outside room ${roomId} bounds`); callback(null); return; } @@ -230,21 +252,41 @@ export class NPCPathfindingManager { const endTileX = Math.floor((endX - bounds.worldX) / TILE_SIZE); const endTileY = Math.floor((endY - bounds.worldY) / TILE_SIZE); - // Clamp to valid tile ranges + // Clamp to valid tile ranges (should already be valid but safety check) const clampedStartX = Math.max(0, Math.min(bounds.mapWidth - 1, startTileX)); const clampedStartY = Math.max(0, Math.min(bounds.mapHeight - 1, startTileY)); const clampedEndX = Math.max(0, Math.min(bounds.mapWidth - 1, endTileX)); const clampedEndY = Math.max(0, Math.min(bounds.mapHeight - 1, endTileY)); + // Warn if coordinates were clamped (indicates out of bounds request) + if (startTileX !== clampedStartX || startTileY !== clampedStartY) { + console.warn(`⚠️ Start position was clamped from (${startTileX}, ${startTileY}) to (${clampedStartX}, ${clampedStartY})`); + } + if (endTileX !== clampedEndX || endTileY !== clampedEndY) { + console.warn(`⚠️ End position was clamped from (${endTileX}, ${endTileY}) to (${clampedEndX}, ${clampedEndY})`); + } + // Find path pathfinder.findPath(clampedStartX, clampedStartY, clampedEndX, clampedEndY, (tilePath) => { if (tilePath && tilePath.length > 0) { - // Convert tile path to world path + // Convert tile path to world path, ensuring all waypoints are within room bounds const worldPath = tilePath.map(point => ({ x: bounds.worldX + point.x * TILE_SIZE + TILE_SIZE / 2, y: bounds.worldY + point.y * TILE_SIZE + TILE_SIZE / 2 })); + // Validate all waypoints are within the room + const validPath = worldPath.every(wp => + wp.x >= roomWorldMinX && wp.x <= roomWorldMaxX && + wp.y >= roomWorldMinY && wp.y <= roomWorldMaxY + ); + + if (!validPath) { + console.warn(`⚠️ Generated path contains waypoints outside room ${roomId} bounds`); + callback(null); + return; + } + callback(worldPath); } else { callback(null); @@ -254,6 +296,98 @@ export class NPCPathfindingManager { pathfinder.calculate(); } + /** + * Check if there's a clear line of sight between two points + * Uses Bresenham's line algorithm to check all tiles along the path + * Also checks adjacent tiles to account for NPC body width (20px ~= 0.6 tiles) + * + * @param {string} roomId - Room identifier + * @param {number} startX - Start world X + * @param {number} startY - Start world Y + * @param {number} endX - End world X + * @param {number} endY - End world Y + * @returns {boolean} - True if path is clear (all tiles walkable) + */ + hasLineOfSight(roomId, startX, startY, endX, endY) { + const grid = this.grids.get(roomId); + const bounds = this.roomBounds.get(roomId); + + if (!grid || !bounds) { + return false; + } + + // Bounds check: ensure both points are within room (with tolerance) + const roomMinX = bounds.worldX; + const roomMinY = bounds.worldY; + const roomMaxX = bounds.worldX + (bounds.mapWidth * TILE_SIZE); + const roomMaxY = bounds.worldY + (bounds.mapHeight * TILE_SIZE); + + if (startX < roomMinX || startX > roomMaxX || startY < roomMinY || startY > roomMaxY) { + return false; // Start position outside room + } + if (endX < roomMinX || endX > roomMaxX || endY < roomMinY || endY > roomMaxY) { + 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); + + // Helper function to check if a tile is walkable + const isTileWalkable = (tx, ty) => { + if (ty < 0 || ty >= grid.length || tx < 0 || tx >= grid[0].length) { + return false; // Out of bounds + } + return grid[ty][tx] === 0; // 0 = walkable + }; + + // Bresenham's line algorithm to check all tiles along the line + const dx = Math.abs(endTileX - startTileX); + const dy = Math.abs(endTileY - startTileY); + const sx = startTileX < endTileX ? 1 : -1; + const sy = startTileY < endTileY ? 1 : -1; + let err = dx - dy; + + let x = startTileX; + let y = startTileY; + + while (true) { + // Check if current tile and adjacent tiles are walkable + // NPCs are ~20px wide (0.6 tiles), so check the main tile plus one adjacent + if (!isTileWalkable(x, y)) { + return false; // Center tile blocked + } + + // For diagonal movement, check that adjacent tiles are also clear + // This prevents NPCs from trying to squeeze through diagonal gaps + if (dx > 0 && dy > 0) { // Moving diagonally + // Check the perpendicular tiles to ensure enough clearance + if (!isTileWalkable(x + sx, y) || !isTileWalkable(x, y + sy)) { + return false; // Diagonal squeeze - not enough clearance + } + } + + // Reached end point + if (x === endTileX && y === endTileY) { + break; + } + + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x += sx; + } + if (e2 < dx) { + err += dx; + y += sy; + } + } + + return true; // Clear path + } + /** * Get random valid position within patrol bounds * Ensures position is walkable (not on a wall)