feat: Enhance NPC pathfinding and line of sight checks for improved chase behavior across rooms

This commit is contained in:
Z. Cliffe Schreuders
2026-02-15 23:14:13 +00:00
parent af8b0e1cb3
commit f1ac7ee383
2 changed files with 338 additions and 9 deletions

View File

@@ -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,54 @@ 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;
// 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;
// Calculate normalized direction
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 +1140,117 @@ class NPCBehavior {
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;
this.sprite.body.setVelocity(
normalizedDx * chaseSpeed,
normalizedDy * chaseSpeed
);
this.direction = this.calculateDirection(dx, dy);
this.playAnimation('walk', this.direction);
this.isMoving = true;
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);

View File

@@ -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)