mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
WiP pathfinding
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user