mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-23 04:08:03 +00:00
Complete hostile NPC combat system with chase behavior and integration: **Phase 6: Hostile NPC Behavior** - Modified npc-behavior.js determineState() to check hostile state from npcHostileSystem - Implemented updateHostileBehavior() with chase and attack logic: - NPCs chase player when hostile and in aggro range - NPCs stop and attack when in attack range - NPCs use directional movement with proper animations - Integration with npcCombat system for attack attempts - Added KO state check to prevent KO'd NPCs from acting **Phase 7: Final Integration** - Modified player.js to disable movement when player is KO - Added visual KO effect (50% alpha) to NPC sprites in npc-hostile.js - Connected all combat systems end-to-end: - Ink dialogue → hostile tag → hostile state → chase behavior → combat - Player interaction → punch → NPC damage → KO → visual feedback - NPC chase → attack → player damage → HP UI → game over Full combat loop now functional: hostile NPCs chase and attack player, player can punch hostile NPCs, complete visual/audio feedback, game over on KO.
1165 lines
44 KiB
JavaScript
1165 lines
44 KiB
JavaScript
/**
|
|
* NPC Behavior System - Core Behavior Management
|
|
*
|
|
* Manages all NPC behaviors including:
|
|
* - Face Player: Turn to face player when nearby
|
|
* - Patrol: Random movement within area (using EasyStar.js pathfinding)
|
|
* - Personal Space: Back away if player too close
|
|
* - Hostile: Red tint, future chase/flee behaviors
|
|
*
|
|
* Architecture:
|
|
* - NPCBehaviorManager: Singleton manager for all NPC behaviors
|
|
* - NPCBehavior: Individual behavior instance per NPC
|
|
* - NPCPathfindingManager: Manages EasyStar pathfinding per room
|
|
*
|
|
* Lifecycle:
|
|
* - Manager initialized once in game.js create()
|
|
* - Behaviors registered per-room when sprites created
|
|
* - Updated every frame (throttled to 50ms)
|
|
* - Rooms never unload, so no cleanup needed
|
|
*
|
|
* @module npc-behavior
|
|
*/
|
|
|
|
import { TILE_SIZE } from '../utils/constants.js?v=8';
|
|
import { NPCPathfindingManager } from './npc-pathfinding.js?v=2';
|
|
|
|
/**
|
|
* NPCBehaviorManager - Manages all NPC behaviors
|
|
*
|
|
* Initialized once in game.js create() phase
|
|
* Updated every frame in game.js update() phase
|
|
*
|
|
* IMPORTANT: Rooms never unload, so no lifecycle management needed.
|
|
* Behaviors persist for entire game session once registered.
|
|
*/
|
|
export class NPCBehaviorManager {
|
|
constructor(scene, npcManager) {
|
|
this.scene = scene; // Phaser scene reference
|
|
this.npcManager = npcManager; // NPC Manager reference
|
|
this.behaviors = new Map(); // Map<npcId, NPCBehavior>
|
|
this.updateInterval = 50; // Update behaviors every 50ms
|
|
this.lastUpdate = 0;
|
|
|
|
// Use the pathfinding manager created by initializeRooms()
|
|
// It's already been initialized in rooms.js and should be available on window
|
|
this.pathfindingManager = window.pathfindingManager;
|
|
|
|
if (!this.pathfindingManager) {
|
|
console.warn(`⚠️ Pathfinding manager not yet available, will use window.pathfindingManager when needed`);
|
|
}
|
|
|
|
console.log('✅ NPCBehaviorManager initialized');
|
|
}
|
|
|
|
/**
|
|
* Get pathfinding manager (used by NPCBehavior instances)
|
|
* Retrieves from window.pathfindingManager to ensure latest reference
|
|
*/
|
|
getPathfindingManager() {
|
|
return window.pathfindingManager || this.pathfindingManager;
|
|
}
|
|
|
|
/**
|
|
* Register a behavior instance for an NPC sprite
|
|
* Called when NPC sprite is created in createNPCSpritesForRoom()
|
|
*
|
|
* No unregister needed - rooms never unload, sprites persist
|
|
*/
|
|
registerBehavior(npcId, sprite, config) {
|
|
try {
|
|
// Get latest pathfinding manager reference
|
|
const pathfindingManager = window.pathfindingManager || this.pathfindingManager;
|
|
const behavior = new NPCBehavior(npcId, sprite, config, this.scene, pathfindingManager);
|
|
this.behaviors.set(npcId, behavior);
|
|
console.log(`🤖 Behavior registered for ${npcId}`);
|
|
} catch (error) {
|
|
console.error(`❌ Failed to register behavior for ${npcId}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main update loop (called from game.js update())
|
|
*/
|
|
update(time, delta) {
|
|
// Throttle updates to every 50ms for performance
|
|
if (time - this.lastUpdate < this.updateInterval) {
|
|
return;
|
|
}
|
|
this.lastUpdate = time;
|
|
|
|
// Get player position once for all behaviors
|
|
const player = window.player;
|
|
if (!player) {
|
|
return; // No player yet
|
|
}
|
|
const playerPos = { x: player.x, y: player.y };
|
|
|
|
for (const [npcId, behavior] of this.behaviors) {
|
|
behavior.update(time, delta, playerPos);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update behavior config (called from Ink tag handlers)
|
|
*/
|
|
setBehaviorState(npcId, property, value) {
|
|
const behavior = this.behaviors.get(npcId);
|
|
if (behavior) {
|
|
behavior.setState(property, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get behavior instance for an NPC
|
|
*/
|
|
getBehavior(npcId) {
|
|
return this.behaviors.get(npcId) || null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* NPCBehavior - Individual NPC behavior instance
|
|
*/
|
|
class NPCBehavior {
|
|
constructor(npcId, sprite, config, scene, pathfindingManager) {
|
|
this.npcId = npcId;
|
|
this.sprite = sprite;
|
|
this.scene = scene;
|
|
// Store pathfinding manager, but prefer window.pathfindingManager if available
|
|
this.pathfindingManager = pathfindingManager || window.pathfindingManager;
|
|
|
|
// Validate sprite reference
|
|
if (!this.sprite || !this.sprite.body) {
|
|
throw new Error(`❌ Invalid sprite provided for NPC ${npcId}`);
|
|
}
|
|
|
|
// Get NPC data and validate room ID
|
|
const npcData = window.npcManager?.npcs?.get(npcId);
|
|
if (!npcData || !npcData.roomId) {
|
|
console.warn(`⚠️ NPC ${npcId} has no room assignment, using default`);
|
|
this.roomId = 'unknown';
|
|
} else {
|
|
this.roomId = npcData.roomId;
|
|
}
|
|
|
|
// Verify sprite reference matches stored sprite
|
|
if (npcData && npcData._sprite && npcData._sprite !== this.sprite) {
|
|
console.warn(`⚠️ Sprite reference mismatch for ${npcId}`);
|
|
}
|
|
|
|
this.config = this.parseConfig(config || {});
|
|
|
|
// State
|
|
this.currentState = 'idle';
|
|
this.direction = 'down'; // Current facing direction
|
|
this.hostile = this.config.hostile.defaultState;
|
|
this.influence = 0;
|
|
|
|
// Patrol state
|
|
this.patrolTarget = null;
|
|
this.currentPath = []; // Current path from EasyStar pathfinding
|
|
this.pathIndex = 0; // Current position in path
|
|
this.lastPatrolChange = 0;
|
|
this.lastPosition = { x: this.sprite.x, y: this.sprite.y };
|
|
this.collisionRotationAngle = 0; // Clockwise rotation angle when blocked (0-360)
|
|
this.wasBlockedLastFrame = false; // Track block state for smooth transitions
|
|
|
|
// Personal space state
|
|
this.backingAway = false;
|
|
|
|
// Animation tracking
|
|
this.lastAnimationKey = null;
|
|
this.isMoving = false;
|
|
|
|
// Apply initial hostile visual if needed
|
|
if (this.hostile) {
|
|
this.setHostile(true);
|
|
}
|
|
|
|
console.log(`✅ Behavior initialized for ${npcId} in room ${this.roomId}`);
|
|
}
|
|
|
|
parseConfig(config) {
|
|
// Parse and apply defaults to config
|
|
const merged = {
|
|
facePlayer: config.facePlayer !== undefined ? config.facePlayer : true,
|
|
facePlayerDistance: config.facePlayerDistance || 96,
|
|
patrol: {
|
|
enabled: config.patrol?.enabled || false,
|
|
speed: config.patrol?.speed || 100,
|
|
changeDirectionInterval: config.patrol?.changeDirectionInterval || 3000,
|
|
bounds: config.patrol?.bounds || null,
|
|
waypoints: config.patrol?.waypoints || null, // List of waypoints
|
|
waypointMode: config.patrol?.waypointMode || 'sequential', // sequential or random
|
|
waypointIndex: 0, // Current waypoint index for sequential mode
|
|
// Multi-room route support
|
|
multiRoom: config.patrol?.multiRoom || false, // Enable multi-room patrolling
|
|
route: config.patrol?.route || null, // Array of {room, waypoints} segments
|
|
currentSegmentIndex: 0 // Current segment in route
|
|
},
|
|
personalSpace: {
|
|
enabled: config.personalSpace?.enabled || false,
|
|
distance: config.personalSpace?.distance || 48,
|
|
backAwaySpeed: config.personalSpace?.backAwaySpeed || 30,
|
|
backAwayDistance: config.personalSpace?.backAwayDistance || 5
|
|
},
|
|
hostile: {
|
|
defaultState: config.hostile?.defaultState || false,
|
|
influenceThreshold: config.hostile?.influenceThreshold || -50,
|
|
chaseSpeed: config.hostile?.chaseSpeed || 200,
|
|
fleeSpeed: config.hostile?.fleeSpeed || 180,
|
|
aggroDistance: config.hostile?.aggroDistance || 160
|
|
}
|
|
};
|
|
|
|
// Pre-calculate squared distances for performance
|
|
merged.facePlayerDistanceSq = merged.facePlayerDistance ** 2;
|
|
merged.personalSpace.distanceSq = merged.personalSpace.distance ** 2;
|
|
merged.hostile.aggroDistanceSq = merged.hostile.aggroDistance ** 2;
|
|
|
|
// Validate multi-room route if provided
|
|
if (merged.patrol.enabled && merged.patrol.multiRoom && merged.patrol.route && merged.patrol.route.length > 0) {
|
|
this.validateMultiRoomRoute(merged);
|
|
}
|
|
|
|
// Validate and process waypoints if provided (single-room or first room of multi-room)
|
|
if (merged.patrol.enabled && merged.patrol.waypoints && merged.patrol.waypoints.length > 0) {
|
|
this.validateWaypoints(merged);
|
|
}
|
|
|
|
// Validate patrol bounds include starting position (only if no waypoints)
|
|
if (merged.patrol.enabled && merged.patrol.bounds && (!merged.patrol.waypoints || merged.patrol.waypoints.length === 0)) {
|
|
const bounds = merged.patrol.bounds;
|
|
const spriteX = this.sprite.x;
|
|
const spriteY = this.sprite.y;
|
|
|
|
// Get room offset for bounds calculation
|
|
const roomData = window.rooms ? window.rooms[this.roomId] : null;
|
|
const roomWorldX = roomData?.worldX || 0;
|
|
const roomWorldY = roomData?.worldY || 0;
|
|
|
|
// Convert bounds to world coordinates
|
|
const worldBounds = {
|
|
x: roomWorldX + bounds.x,
|
|
y: roomWorldY + bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
};
|
|
|
|
const inBoundsX = spriteX >= worldBounds.x && spriteX <= (worldBounds.x + worldBounds.width);
|
|
const inBoundsY = spriteY >= worldBounds.y && spriteY <= (worldBounds.y + worldBounds.height);
|
|
|
|
if (!inBoundsX || !inBoundsY) {
|
|
console.warn(`⚠️ NPC ${this.npcId} starting position (${spriteX}, ${spriteY}) is outside patrol bounds. Expanding bounds...`);
|
|
|
|
// Auto-expand bounds to include starting position
|
|
const newX = Math.min(worldBounds.x, spriteX);
|
|
const newY = Math.min(worldBounds.y, spriteY);
|
|
const newMaxX = Math.max(worldBounds.x + worldBounds.width, spriteX);
|
|
const newMaxY = Math.max(worldBounds.y + worldBounds.height, spriteY);
|
|
|
|
// Store bounds in world coordinates for easier calculation
|
|
merged.patrol.worldBounds = {
|
|
x: newX,
|
|
y: newY,
|
|
width: newMaxX - newX,
|
|
height: newMaxY - newY
|
|
};
|
|
|
|
console.log(`✅ Patrol bounds expanded to include starting position`);
|
|
} else {
|
|
// Store bounds in world coordinates
|
|
merged.patrol.worldBounds = worldBounds;
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Validate and process waypoints from scenario config
|
|
* Converts tile coordinates to world coordinates
|
|
* Validates waypoints are walkable
|
|
*/
|
|
validateWaypoints(merged) {
|
|
try {
|
|
const roomData = window.rooms ? window.rooms[this.roomId] : null;
|
|
if (!roomData) {
|
|
console.warn(`⚠️ Cannot validate waypoints: room ${this.roomId} not found`);
|
|
merged.patrol.waypoints = null;
|
|
return;
|
|
}
|
|
|
|
const roomWorldX = roomData.worldX || 0;
|
|
const roomWorldY = roomData.worldY || 0;
|
|
|
|
const validWaypoints = [];
|
|
|
|
for (const wp of merged.patrol.waypoints) {
|
|
// Validate waypoint has x, y
|
|
if (wp.x === undefined || wp.y === undefined) {
|
|
console.warn(`⚠️ Waypoint missing x or y coordinate`);
|
|
continue;
|
|
}
|
|
|
|
// Convert tile coordinates to world coordinates
|
|
const worldX = roomWorldX + (wp.x * TILE_SIZE);
|
|
const worldY = roomWorldY + (wp.y * TILE_SIZE);
|
|
|
|
// Basic bounds check
|
|
const roomBounds = window.pathfindingManager?.getBounds(this.roomId);
|
|
if (roomBounds) {
|
|
// Convert tile bounds to world coordinates for comparison
|
|
const minWorldX = roomWorldX + (roomBounds.x * TILE_SIZE);
|
|
const minWorldY = roomWorldY + (roomBounds.y * TILE_SIZE);
|
|
const maxWorldX = minWorldX + (roomBounds.width * TILE_SIZE);
|
|
const maxWorldY = minWorldY + (roomBounds.height * TILE_SIZE);
|
|
|
|
if (worldX < minWorldX || worldX > maxWorldX || worldY < minWorldY || worldY > maxWorldY) {
|
|
console.warn(`⚠️ Waypoint (${wp.x}, ${wp.y}) at world (${worldX}, ${worldY}) outside patrol bounds`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Store validated waypoint with world coordinates
|
|
validWaypoints.push({
|
|
tileX: wp.x,
|
|
tileY: wp.y,
|
|
worldX: worldX,
|
|
worldY: worldY,
|
|
dwellTime: wp.dwellTime || 0
|
|
});
|
|
}
|
|
|
|
if (validWaypoints.length > 0) {
|
|
merged.patrol.waypoints = validWaypoints;
|
|
merged.patrol.waypointIndex = 0;
|
|
console.log(`✅ Validated ${validWaypoints.length} waypoints for ${this.npcId}`);
|
|
} else {
|
|
console.warn(`⚠️ No valid waypoints for ${this.npcId}, using random patrol`);
|
|
merged.patrol.waypoints = null;
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ Error validating waypoints for ${this.npcId}:`, error);
|
|
merged.patrol.waypoints = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate multi-room route configuration
|
|
* Checks that all rooms exist and are properly connected
|
|
* Pre-loads all route rooms for immediate access
|
|
*/
|
|
validateMultiRoomRoute(merged) {
|
|
try {
|
|
const gameScenario = window.gameScenario;
|
|
if (!gameScenario || !gameScenario.rooms) {
|
|
console.warn(`⚠️ No scenario rooms available, disabling multi-room route for ${this.npcId}`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
|
|
const route = merged.patrol.route;
|
|
if (!Array.isArray(route) || route.length === 0) {
|
|
console.warn(`⚠️ Invalid route for ${this.npcId}, disabling multi-room`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
|
|
// Validate all rooms in route exist
|
|
for (let i = 0; i < route.length; i++) {
|
|
const segment = route[i];
|
|
if (!segment.room) {
|
|
console.warn(`⚠️ Route segment ${i} missing room ID for ${this.npcId}`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
|
|
if (!gameScenario.rooms[segment.room]) {
|
|
console.warn(`⚠️ Route room "${segment.room}" not found in scenario for ${this.npcId}`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
|
|
// Validate waypoints in this segment
|
|
if (segment.waypoints && Array.isArray(segment.waypoints)) {
|
|
for (const wp of segment.waypoints) {
|
|
if (wp.x === undefined || wp.y === undefined) {
|
|
console.warn(`⚠️ Route segment ${i} (room: ${segment.room}) has invalid waypoint`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate connections between consecutive rooms
|
|
for (let i = 0; i < route.length; i++) {
|
|
const currentRoom = route[i].room;
|
|
const nextRoomIndex = (i + 1) % route.length; // Loop back to first room
|
|
const nextRoom = route[nextRoomIndex].room;
|
|
|
|
const currentRoomData = gameScenario.rooms[currentRoom];
|
|
const connections = currentRoomData.connections || {};
|
|
|
|
// Check if there's a door connecting current room to next room
|
|
let isConnected = false;
|
|
for (const [direction, connectedRooms] of Object.entries(connections)) {
|
|
const roomList = Array.isArray(connectedRooms) ? connectedRooms : [connectedRooms];
|
|
if (roomList.includes(nextRoom)) {
|
|
isConnected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isConnected) {
|
|
console.warn(`⚠️ Route rooms not connected: ${currentRoom} ↔ ${nextRoom} for ${this.npcId}`);
|
|
merged.patrol.multiRoom = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Pre-load all route rooms
|
|
console.log(`🚪 Pre-loading ${route.length} rooms for multi-room route: ${route.map(r => r.room).join(' → ')}`);
|
|
for (const segment of route) {
|
|
const roomId = segment.room;
|
|
if (window.rooms && !window.rooms[roomId]) {
|
|
// Pre-load the room if not already loaded
|
|
window.loadRoom(roomId).catch(err => {
|
|
console.warn(`⚠️ Failed to pre-load room ${roomId}:`, err);
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`✅ Multi-room route validated for ${this.npcId} with ${route.length} segments`);
|
|
} catch (error) {
|
|
console.error(`❌ Error validating multi-room route for ${this.npcId}:`, error);
|
|
merged.patrol.multiRoom = false;
|
|
}
|
|
}
|
|
|
|
update(time, delta, playerPos) {
|
|
try {
|
|
// Validate sprite
|
|
if (!this.sprite || !this.sprite.body || this.sprite.destroyed) {
|
|
console.warn(`⚠️ Invalid sprite for ${this.npcId}, skipping update`);
|
|
return;
|
|
}
|
|
|
|
// Main behavior update logic
|
|
// 1. Determine highest priority state
|
|
const state = this.determineState(playerPos);
|
|
|
|
// 2. Execute state behavior
|
|
this.executeState(state, time, delta, playerPos);
|
|
|
|
// 3. CRITICAL: Update depth after any movement
|
|
// This ensures correct Y-sorting with player and other NPCs
|
|
this.updateDepth();
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Behavior update error for ${this.npcId}:`, error);
|
|
}
|
|
}
|
|
|
|
determineState(playerPos) {
|
|
if (!playerPos) {
|
|
return 'idle';
|
|
}
|
|
|
|
// Calculate distance to player
|
|
const dx = playerPos.x - this.sprite.x;
|
|
const dy = playerPos.y - this.sprite.y;
|
|
const distanceSq = dx * dx + dy * dy;
|
|
|
|
// Check hostile state from hostile system (overrides config)
|
|
const isHostile = window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(this.npcId);
|
|
const isKO = window.npcHostileSystem && window.npcHostileSystem.isNPCKO(this.npcId);
|
|
|
|
// If KO, always idle
|
|
if (isKO) {
|
|
return 'idle';
|
|
}
|
|
|
|
// Priority 5: Chase (hostile + in range)
|
|
if (isHostile && distanceSq < this.config.hostile.aggroDistanceSq) {
|
|
return 'chase';
|
|
}
|
|
|
|
// Priority 3: Maintain Personal Space
|
|
if (this.config.personalSpace.enabled && distanceSq < this.config.personalSpace.distanceSq) {
|
|
return 'maintain_space';
|
|
}
|
|
|
|
// Priority 2: Patrol
|
|
if (this.config.patrol.enabled) {
|
|
// Check if player is in interaction range - if so, face player instead
|
|
if (distanceSq < this.config.facePlayerDistanceSq && this.config.facePlayer) {
|
|
return 'face_player';
|
|
}
|
|
return 'patrol';
|
|
}
|
|
|
|
// Priority 1: Face Player
|
|
if (this.config.facePlayer && distanceSq < this.config.facePlayerDistanceSq) {
|
|
return 'face_player';
|
|
}
|
|
|
|
// Priority 0: Idle
|
|
return 'idle';
|
|
}
|
|
|
|
executeState(state, time, delta, playerPos) {
|
|
this.currentState = state;
|
|
|
|
switch (state) {
|
|
case 'idle':
|
|
this.sprite.body.setVelocity(0, 0);
|
|
this.playAnimation('idle', this.direction);
|
|
this.isMoving = false;
|
|
break;
|
|
|
|
case 'face_player':
|
|
this.facePlayer(playerPos);
|
|
this.sprite.body.setVelocity(0, 0);
|
|
this.isMoving = false;
|
|
break;
|
|
|
|
case 'patrol':
|
|
this.updatePatrol(time, delta);
|
|
break;
|
|
|
|
case 'maintain_space':
|
|
this.maintainPersonalSpace(playerPos, delta);
|
|
break;
|
|
|
|
case 'chase':
|
|
// Stub for future implementation
|
|
this.updateHostileBehavior(playerPos, delta);
|
|
break;
|
|
|
|
case 'flee':
|
|
// Stub for future implementation
|
|
this.updateHostileBehavior(playerPos, delta);
|
|
break;
|
|
}
|
|
}
|
|
|
|
facePlayer(playerPos) {
|
|
if (!this.config.facePlayer || !playerPos) return;
|
|
|
|
const dx = playerPos.x - this.sprite.x;
|
|
const dy = playerPos.y - this.sprite.y;
|
|
|
|
// Calculate direction (8-way)
|
|
this.direction = this.calculateDirection(dx, dy);
|
|
|
|
// Play idle animation facing player
|
|
this.playAnimation('idle', this.direction);
|
|
}
|
|
updatePatrol(time, delta) {
|
|
if (!this.config.patrol.enabled) return;
|
|
|
|
// Check if path needs recalculation (e.g., after NPC-to-NPC collision avoidance)
|
|
if (this._needsPathRecalc && this.patrolTarget) {
|
|
this._needsPathRecalc = false;
|
|
console.log(`🔄 [${this.npcId}] Recalculating path to waypoint after collision avoidance`);
|
|
|
|
// Clear current path and recalculate
|
|
this.currentPath = [];
|
|
this.pathIndex = 0;
|
|
|
|
const pathfindingManager = this.pathfindingManager || window.pathfindingManager;
|
|
if (pathfindingManager) {
|
|
pathfindingManager.findPath(
|
|
this.roomId,
|
|
this.sprite.x,
|
|
this.sprite.y,
|
|
this.patrolTarget.x,
|
|
this.patrolTarget.y,
|
|
(path) => {
|
|
if (path && path.length > 0) {
|
|
this.currentPath = path;
|
|
this.pathIndex = 0;
|
|
console.log(`✅ [${this.npcId}] Recalculated path with ${path.length} waypoints after collision`);
|
|
} else {
|
|
console.warn(`⚠️ [${this.npcId}] Path recalculation failed after collision`);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle dwell time at waypoint
|
|
if (this.patrolTarget && this.patrolTarget.dwellTime && this.patrolTarget.dwellTime > 0) {
|
|
if (this.patrolReachedTime === 0) {
|
|
// Just reached waypoint, start dwell timer
|
|
this.patrolReachedTime = time;
|
|
this.sprite.body.setVelocity(0, 0);
|
|
this.playAnimation('idle', this.direction);
|
|
this.isMoving = false;
|
|
console.log(`⏸️ [${this.npcId}] Dwelling at waypoint for ${this.patrolTarget.dwellTime}ms`);
|
|
return;
|
|
}
|
|
|
|
// Check if dwell time expired
|
|
const dwellElapsed = time - this.patrolReachedTime;
|
|
if (dwellElapsed < this.patrolTarget.dwellTime) {
|
|
// Still dwelling - face player if configured and in range
|
|
const playerPos = window.player?.sprite ? { x: window.player.sprite.x, y: window.player.sprite.y } : null;
|
|
if (playerPos) {
|
|
const distSq = (this.sprite.x - playerPos.x) ** 2 + (this.sprite.y - playerPos.y) ** 2;
|
|
if (distSq < this.config.facePlayerDistanceSq && this.config.facePlayer) {
|
|
this.facePlayer(playerPos);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Dwell time expired, reset and choose next target
|
|
this.patrolReachedTime = 0;
|
|
this.chooseNewPatrolTarget(time);
|
|
return;
|
|
}
|
|
|
|
// Time to choose a new patrol target?
|
|
if (!this.patrolTarget ||
|
|
this.currentPath.length === 0 ||
|
|
time - this.lastPatrolChange > this.config.patrol.changeDirectionInterval) {
|
|
this.chooseNewPatrolTarget(time);
|
|
return;
|
|
}
|
|
|
|
// Follow current path
|
|
if (this.currentPath.length > 0 && this.pathIndex < this.currentPath.length) {
|
|
const nextWaypoint = this.currentPath[this.pathIndex];
|
|
const dx = nextWaypoint.x - this.sprite.x;
|
|
const dy = nextWaypoint.y - this.sprite.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Reached waypoint? Move to next
|
|
if (distance < 8) {
|
|
this.pathIndex++;
|
|
|
|
// Reached end of path? Choose new target
|
|
if (this.pathIndex >= this.currentPath.length) {
|
|
this.patrolReachedTime = time; // Mark when we reached the final waypoint
|
|
this.chooseNewPatrolTarget(time);
|
|
return;
|
|
}
|
|
return; // Let next frame handle the new waypoint
|
|
}
|
|
|
|
// Move toward current waypoint
|
|
const velocityX = (dx / distance) * this.config.patrol.speed;
|
|
const velocityY = (dy / distance) * this.config.patrol.speed;
|
|
this.sprite.body.setVelocity(velocityX, velocityY);
|
|
|
|
// Update direction and animation
|
|
this.direction = this.calculateDirection(dx, dy);
|
|
this.playAnimation('walk', this.direction);
|
|
this.isMoving = true;
|
|
|
|
// console.log(`🚶 [${this.npcId}] Patrol waypoint ${this.pathIndex + 1}/${this.currentPath.length} - velocity: (${velocityX.toFixed(0)}, ${velocityY.toFixed(0)})`);
|
|
} else {
|
|
// No path found, choose new target
|
|
this.chooseNewPatrolTarget(time);
|
|
}
|
|
}
|
|
|
|
chooseNewPatrolTarget(time) {
|
|
// Check if using waypoint patrol
|
|
if (this.config.patrol.waypoints && this.config.patrol.waypoints.length > 0) {
|
|
this.chooseWaypointTarget(time);
|
|
} else {
|
|
// Fall back to random patrol
|
|
this.chooseRandomPatrolTarget(time);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Choose target from waypoint list (single-room or multi-room)
|
|
*/
|
|
chooseWaypointTarget(time) {
|
|
// Handle multi-room routes
|
|
if (this.config.patrol.multiRoom && this.config.patrol.route && this.config.patrol.route.length > 0) {
|
|
this.chooseWaypointTargetMultiRoom(time);
|
|
return;
|
|
}
|
|
|
|
// Single-room waypoint patrol
|
|
let nextWaypoint;
|
|
|
|
if (this.config.patrol.waypointMode === 'sequential') {
|
|
// Sequential: follow waypoints in order
|
|
nextWaypoint = this.config.patrol.waypoints[this.config.patrol.waypointIndex];
|
|
this.config.patrol.waypointIndex = (this.config.patrol.waypointIndex + 1) % this.config.patrol.waypoints.length;
|
|
} else {
|
|
// Random: pick random waypoint
|
|
const randomIndex = Math.floor(Math.random() * this.config.patrol.waypoints.length);
|
|
nextWaypoint = this.config.patrol.waypoints[randomIndex];
|
|
}
|
|
|
|
if (!nextWaypoint) {
|
|
console.warn(`⚠️ [${this.npcId}] No valid waypoint, falling back to random patrol`);
|
|
this.chooseRandomPatrolTarget(time);
|
|
return;
|
|
}
|
|
|
|
this.patrolTarget = {
|
|
x: nextWaypoint.worldX,
|
|
y: nextWaypoint.worldY,
|
|
dwellTime: nextWaypoint.dwellTime || 0
|
|
};
|
|
|
|
this.lastPatrolChange = time;
|
|
this.pathIndex = 0;
|
|
this.currentPath = [];
|
|
this.patrolReachedTime = 0;
|
|
|
|
// Request pathfinding to waypoint
|
|
const pathfindingManager = this.pathfindingManager || window.pathfindingManager;
|
|
if (!pathfindingManager) {
|
|
console.warn(`⚠️ No pathfinding manager for ${this.npcId}`);
|
|
return;
|
|
}
|
|
|
|
pathfindingManager.findPath(
|
|
this.roomId,
|
|
this.sprite.x,
|
|
this.sprite.y,
|
|
nextWaypoint.worldX,
|
|
nextWaypoint.worldY,
|
|
(path) => {
|
|
if (path && path.length > 0) {
|
|
this.currentPath = path;
|
|
this.pathIndex = 0;
|
|
// console.log(`✅ [${this.npcId}] New waypoint path with ${path.length} waypoints to (${nextWaypoint.tileX}, ${nextWaypoint.tileY})`);
|
|
} else {
|
|
// Waypoint is unreachable, NPC will choose a different target next update
|
|
this.currentPath = [];
|
|
this.patrolTarget = null;
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Choose waypoint target for multi-room route
|
|
* Handles transitioning between rooms when waypoints in current room are exhausted
|
|
*/
|
|
chooseWaypointTargetMultiRoom(time) {
|
|
const route = this.config.patrol.route;
|
|
const currentSegmentIndex = this.config.patrol.currentSegmentIndex;
|
|
const currentSegment = route[currentSegmentIndex];
|
|
|
|
// Get current room's waypoints
|
|
let currentRoomWaypoints = currentSegment.waypoints;
|
|
if (!currentRoomWaypoints || !Array.isArray(currentRoomWaypoints) || currentRoomWaypoints.length === 0) {
|
|
// No waypoints in this segment, move to next room
|
|
console.log(`⏭️ [${this.npcId}] No waypoints in current segment, moving to next room`);
|
|
this.transitionToNextRoom(time);
|
|
return;
|
|
}
|
|
|
|
// Get next waypoint in current room
|
|
let nextWaypoint;
|
|
if (this.config.patrol.waypointMode === 'sequential') {
|
|
nextWaypoint = currentRoomWaypoints[this.config.patrol.waypointIndex];
|
|
this.config.patrol.waypointIndex = (this.config.patrol.waypointIndex + 1) % currentRoomWaypoints.length;
|
|
|
|
// Check if we've completed all waypoints in this room
|
|
if (this.config.patrol.waypointIndex === 0) {
|
|
// Just wrapped around - all waypoints done, move to next room
|
|
console.log(`🔄 [${this.npcId}] Completed all waypoints in room ${currentSegment.room}, transitioning...`);
|
|
this.transitionToNextRoom(time);
|
|
return;
|
|
}
|
|
} else {
|
|
// Random: pick random waypoint
|
|
const randomIndex = Math.floor(Math.random() * currentRoomWaypoints.length);
|
|
nextWaypoint = currentRoomWaypoints[randomIndex];
|
|
}
|
|
|
|
if (!nextWaypoint) {
|
|
console.warn(`⚠️ [${this.npcId}] No valid waypoint in multi-room route`);
|
|
this.chooseRandomPatrolTarget(time);
|
|
return;
|
|
}
|
|
|
|
// Convert tile coordinates to world coordinates for current room
|
|
const roomData = window.rooms?.[currentSegment.room];
|
|
if (!roomData) {
|
|
console.warn(`⚠️ Room ${currentSegment.room} not loaded for multi-room navigation`);
|
|
this.chooseRandomPatrolTarget(time);
|
|
return;
|
|
}
|
|
|
|
const roomWorldX = roomData.position?.x || 0;
|
|
const roomWorldY = roomData.position?.y || 0;
|
|
const worldX = roomWorldX + (nextWaypoint.x * TILE_SIZE);
|
|
const worldY = roomWorldY + (nextWaypoint.y * TILE_SIZE);
|
|
|
|
this.patrolTarget = {
|
|
x: worldX,
|
|
y: worldY,
|
|
dwellTime: nextWaypoint.dwellTime || 0
|
|
};
|
|
|
|
this.lastPatrolChange = time;
|
|
this.pathIndex = 0;
|
|
this.currentPath = [];
|
|
this.patrolReachedTime = 0;
|
|
|
|
// Request pathfinding to waypoint in current room
|
|
const pathfindingManager = this.pathfindingManager || window.pathfindingManager;
|
|
if (!pathfindingManager) {
|
|
console.warn(`⚠️ No pathfinding manager for ${this.npcId}`);
|
|
return;
|
|
}
|
|
|
|
pathfindingManager.findPath(
|
|
currentSegment.room,
|
|
this.sprite.x,
|
|
this.sprite.y,
|
|
worldX,
|
|
worldY,
|
|
(path) => {
|
|
if (path && path.length > 0) {
|
|
this.currentPath = path;
|
|
this.pathIndex = 0;
|
|
console.log(`✅ [${this.npcId}] Route path with ${path.length} waypoints to (${nextWaypoint.x}, ${nextWaypoint.y}) in ${currentSegment.room}`);
|
|
} else {
|
|
// Waypoint unreachable, try next room
|
|
console.warn(`⚠️ [${this.npcId}] Waypoint unreachable in ${currentSegment.room}, trying next room...`);
|
|
this.transitionToNextRoom(time);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Transition NPC to the next room in the multi-room route
|
|
* Finds connecting door and relocates sprite
|
|
*/
|
|
transitionToNextRoom(time) {
|
|
const route = this.config.patrol.route;
|
|
if (!route || route.length === 0) {
|
|
console.warn(`⚠️ [${this.npcId}] No route available for room transition`);
|
|
return;
|
|
}
|
|
|
|
// Move to next room in route
|
|
const nextSegmentIndex = (this.config.patrol.currentSegmentIndex + 1) % route.length;
|
|
const currentSegment = route[this.config.patrol.currentSegmentIndex];
|
|
const nextSegment = route[nextSegmentIndex];
|
|
|
|
console.log(`🚪 [${this.npcId}] Transitioning: ${currentSegment.room} → ${nextSegment.room}`);
|
|
|
|
// Update NPC's roomId in npcManager
|
|
const npcData = window.npcManager?.npcs?.get(this.npcId);
|
|
if (npcData) {
|
|
npcData.roomId = nextSegment.room;
|
|
}
|
|
|
|
// Update behavior's room tracking
|
|
this.roomId = nextSegment.room;
|
|
this.config.patrol.currentSegmentIndex = nextSegmentIndex;
|
|
this.config.patrol.waypointIndex = 0;
|
|
|
|
// Relocate sprite to next room
|
|
if (window.relocateNPCSprite) {
|
|
window.relocateNPCSprite(
|
|
this.sprite,
|
|
currentSegment.room,
|
|
nextSegment.room,
|
|
this.npcId
|
|
);
|
|
} else {
|
|
console.warn(`⚠️ relocateNPCSprite not available for ${this.npcId}`);
|
|
}
|
|
|
|
// Choose waypoint in new room
|
|
this.chooseNewPatrolTarget(time);
|
|
}
|
|
|
|
/**
|
|
* Choose random patrol target (original behavior)
|
|
*/
|
|
chooseRandomPatrolTarget(time) {
|
|
// Ensure we have the latest pathfinding manager reference
|
|
const pathfindingManager = this.pathfindingManager || window.pathfindingManager;
|
|
|
|
if (!pathfindingManager) {
|
|
console.warn(`⚠️ No pathfinding manager for ${this.npcId}`);
|
|
return;
|
|
}
|
|
|
|
// Get random target position using pathfinding manager
|
|
const targetPos = pathfindingManager.getRandomPatrolTarget(this.roomId);
|
|
if (!targetPos) {
|
|
console.warn(`⚠️ Could not find random patrol target for ${this.npcId}`);
|
|
// Fall back to idle if can't find a target
|
|
this.sprite.body.setVelocity(0, 0);
|
|
this.playAnimation('idle', this.direction);
|
|
this.isMoving = false;
|
|
return;
|
|
}
|
|
|
|
this.patrolTarget = targetPos;
|
|
this.lastPatrolChange = time;
|
|
this.pathIndex = 0;
|
|
this.currentPath = [];
|
|
|
|
// Request pathfinding from current position to target
|
|
pathfindingManager.findPath(
|
|
this.roomId,
|
|
this.sprite.x,
|
|
this.sprite.y,
|
|
targetPos.x,
|
|
targetPos.y,
|
|
(path) => {
|
|
if (path && path.length > 0) {
|
|
this.currentPath = path;
|
|
this.pathIndex = 0;
|
|
console.log(`✅ [${this.npcId}] New patrol path with ${path.length} waypoints`);
|
|
} else {
|
|
console.warn(`⚠️ [${this.npcId}] Pathfinding failed, target unreachable`);
|
|
this.currentPath = [];
|
|
this.patrolTarget = null;
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
maintainPersonalSpace(playerPos, delta) {
|
|
if (!this.config.personalSpace.enabled || !playerPos) {
|
|
return false;
|
|
}
|
|
|
|
const dx = this.sprite.x - playerPos.x; // Away from player
|
|
const dy = this.sprite.y - playerPos.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance === 0) return false; // Avoid division by zero
|
|
|
|
// Back away using velocity (physics-safe movement)
|
|
// Normalize direction and apply velocity push
|
|
const backAwaySpeed = this.config.personalSpace.backAwaySpeed || 30;
|
|
const velocityX = (dx / distance) * backAwaySpeed;
|
|
const velocityY = (dy / distance) * backAwaySpeed;
|
|
|
|
if (this.sprite.body) {
|
|
this.sprite.body.setVelocity(velocityX, velocityY);
|
|
}
|
|
|
|
// Face player while backing away
|
|
this.direction = this.calculateDirection(-dx, -dy); // Negative = face player
|
|
this.playAnimation('idle', this.direction); // Use idle, not walk
|
|
|
|
this.isMoving = false; // Not "walking", just adjusting position
|
|
this.backingAway = true;
|
|
|
|
return true; // Personal space behavior active
|
|
}
|
|
|
|
updateHostileBehavior(playerPos, delta) {
|
|
if (!playerPos) return false;
|
|
|
|
// Calculate distance to player
|
|
const dx = playerPos.x - this.sprite.x;
|
|
const dy = playerPos.y - this.sprite.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Get attack range from hostile system
|
|
const attackRange = window.npcHostileSystem ?
|
|
window.npcHostileSystem.getState(this.npcId)?.attackRange || 50 : 50;
|
|
|
|
// If in attack range, try to attack
|
|
if (distance <= attackRange) {
|
|
// Stop moving
|
|
this.sprite.body.setVelocity(0, 0);
|
|
this.isMoving = false;
|
|
|
|
// Face player
|
|
this.direction = this.calculateDirection(dx, dy);
|
|
this.playAnimation('idle', this.direction);
|
|
|
|
// Attempt attack
|
|
if (window.npcCombat) {
|
|
window.npcCombat.attemptAttack(this.npcId, this.sprite);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Chase player - move towards them
|
|
const chaseSpeed = this.config.hostile.chaseSpeed || 120;
|
|
|
|
// 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;
|
|
|
|
return true;
|
|
}
|
|
|
|
calculateDirection(dx, dy) {
|
|
const absVX = Math.abs(dx);
|
|
const absVY = Math.abs(dy);
|
|
|
|
// Threshold: if one axis is > 2x the other, consider it pure cardinal
|
|
if (absVX > absVY * 2) {
|
|
return dx > 0 ? 'right' : 'left';
|
|
}
|
|
|
|
if (absVY > absVX * 2) {
|
|
return dy > 0 ? 'down' : 'up';
|
|
}
|
|
|
|
// Diagonal
|
|
if (dy > 0) {
|
|
return dx > 0 ? 'down-right' : 'down-left';
|
|
} else {
|
|
return dx > 0 ? 'up-right' : 'up-left';
|
|
}
|
|
}
|
|
|
|
playAnimation(state, direction) {
|
|
// Map left directions to right with flipX
|
|
let animDirection = direction;
|
|
let flipX = false;
|
|
|
|
if (direction.includes('left')) {
|
|
animDirection = direction.replace('left', 'right');
|
|
flipX = true;
|
|
}
|
|
|
|
const animKey = `npc-${this.npcId}-${state}-${animDirection}`;
|
|
|
|
// Only change animation if different
|
|
if (this.lastAnimationKey !== animKey) {
|
|
// Use scene.anims to check if animation exists in the global animation manager
|
|
if (this.scene?.anims?.exists(animKey)) {
|
|
this.sprite.play(animKey, true);
|
|
this.lastAnimationKey = animKey;
|
|
} else {
|
|
// Fallback: use idle animation if walk doesn't exist
|
|
if (state === 'walk') {
|
|
const idleKey = `npc-${this.npcId}-idle-${animDirection}`;
|
|
if (this.scene?.anims?.exists(idleKey)) {
|
|
this.sprite.play(idleKey, true);
|
|
this.lastAnimationKey = idleKey;
|
|
console.warn(`⚠️ [${this.npcId}] Walk animation missing, using idle: ${idleKey}`);
|
|
} else {
|
|
console.error(`❌ [${this.npcId}] BOTH animations missing! Walk: ${animKey}, Idle: ${idleKey}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set flipX for left-facing directions
|
|
this.sprite.setFlipX(flipX);
|
|
}
|
|
|
|
updateDepth() {
|
|
if (!this.sprite || !this.sprite.body) return;
|
|
|
|
// Calculate depth based on bottom Y position (same as player)
|
|
const spriteBottomY = this.sprite.y + (this.sprite.displayHeight / 2);
|
|
const depth = spriteBottomY + 0.5; // World Y + sprite layer offset
|
|
|
|
// Always update depth - no caching
|
|
// Depth determines Y-sorting, must update every frame for moving NPCs
|
|
this.sprite.setDepth(depth);
|
|
}
|
|
|
|
setState(property, value) {
|
|
switch (property) {
|
|
case 'hostile':
|
|
this.setHostile(value);
|
|
break;
|
|
|
|
case 'influence':
|
|
this.setInfluence(value);
|
|
break;
|
|
|
|
case 'patrol':
|
|
this.config.patrol.enabled = value;
|
|
console.log(`🚶 ${this.npcId} patrol ${value ? 'enabled' : 'disabled'}`);
|
|
break;
|
|
|
|
case 'personalSpaceDistance':
|
|
this.config.personalSpace.distance = value;
|
|
this.config.personalSpace.distanceSq = value ** 2;
|
|
console.log(`↔️ ${this.npcId} personal space: ${value}px`);
|
|
break;
|
|
|
|
default:
|
|
console.warn(`⚠️ Unknown behavior property: ${property}`);
|
|
}
|
|
}
|
|
|
|
setHostile(hostile) {
|
|
if (this.hostile === hostile) return; // No change
|
|
|
|
this.hostile = hostile;
|
|
|
|
// Emit event for other systems to react
|
|
if (window.eventDispatcher) {
|
|
window.eventDispatcher.emit('npc_hostile_changed', {
|
|
npcId: this.npcId,
|
|
hostile: hostile
|
|
});
|
|
}
|
|
|
|
if (hostile) {
|
|
// Red tint (0xff0000 with 50% strength)
|
|
this.sprite.setTint(0xff6666);
|
|
console.log(`🔴 ${this.npcId} is now hostile`);
|
|
} else {
|
|
// Clear tint
|
|
this.sprite.clearTint();
|
|
console.log(`✅ ${this.npcId} is no longer hostile`);
|
|
}
|
|
}
|
|
|
|
setInfluence(influence) {
|
|
this.influence = influence;
|
|
|
|
// Check if influence change should trigger hostile state
|
|
const threshold = this.config.hostile.influenceThreshold;
|
|
|
|
// Auto-trigger hostile if influence drops below threshold
|
|
if (influence < threshold && !this.hostile) {
|
|
this.setHostile(true);
|
|
console.log(`⚠️ ${this.npcId} became hostile due to low influence (${influence} < ${threshold})`);
|
|
}
|
|
// Auto-disable hostile if influence recovers
|
|
else if (influence >= threshold && this.hostile) {
|
|
this.setHostile(false);
|
|
console.log(`✅ ${this.npcId} no longer hostile (influence: ${influence})`);
|
|
}
|
|
|
|
console.log(`💯 ${this.npcId} influence: ${influence}`);
|
|
}
|
|
}
|
|
|
|
// Export for module imports
|
|
export default {
|
|
NPCBehaviorManager,
|
|
NPCBehavior
|
|
};
|