mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
feat: Enhance NPC pathfinding and line of sight checks for improved chase behavior across rooms
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user