mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
408 lines
14 KiB
JavaScript
408 lines
14 KiB
JavaScript
import { COMBAT_CONFIG } from '../config/combat-config.js';
|
|
import { CombatEvents } from '../events/combat-events.js';
|
|
import { getNPCDirection } from './npc-sprites.js';
|
|
|
|
const npcHostileStates = new Map();
|
|
|
|
function createHostileState(npcId, config = {}) {
|
|
return {
|
|
isHostile: false,
|
|
currentHP: config.maxHP || COMBAT_CONFIG.npc.defaultMaxHP,
|
|
maxHP: config.maxHP || COMBAT_CONFIG.npc.defaultMaxHP,
|
|
isKO: false,
|
|
attackDamage: config.attackDamage || COMBAT_CONFIG.npc.defaultPunchDamage,
|
|
attackRange: config.attackRange || COMBAT_CONFIG.npc.defaultPunchRange,
|
|
attackCooldown: config.attackCooldown || COMBAT_CONFIG.npc.defaultAttackCooldown,
|
|
lastAttackTime: 0
|
|
};
|
|
}
|
|
|
|
export function initNPCHostileSystem() {
|
|
console.log('✅ NPC hostile system initialized');
|
|
|
|
return {
|
|
setNPCHostile: (npcId, isHostile, config) => setNPCHostile(npcId, isHostile, config),
|
|
isNPCHostile: (npcId) => isNPCHostile(npcId),
|
|
getState: (npcId) => getNPCHostileState(npcId),
|
|
damageNPC: (npcId, amount) => damageNPC(npcId, amount),
|
|
isNPCKO: (npcId) => isNPCKO(npcId)
|
|
};
|
|
}
|
|
|
|
function setNPCHostile(npcId, isHostile, config) {
|
|
if (!npcId) {
|
|
console.error('setNPCHostile: Invalid NPC ID');
|
|
return false;
|
|
}
|
|
|
|
// Get or create state with config
|
|
let state = npcHostileStates.get(npcId);
|
|
if (!state) {
|
|
// Get attack damage from NPC behavior config if available
|
|
const npcBehavior = window.npcBehaviorManager?.getBehavior(npcId);
|
|
const attackDamage = npcBehavior?.config?.hostile?.attackDamage || config?.attackDamage;
|
|
state = createHostileState(npcId, { attackDamage });
|
|
npcHostileStates.set(npcId, state);
|
|
}
|
|
|
|
const wasHostile = state.isHostile;
|
|
state.isHostile = isHostile;
|
|
|
|
console.log(`⚔️ NPC ${npcId} hostile: ${wasHostile} → ${isHostile}`);
|
|
|
|
// Emit event if state changed
|
|
if (wasHostile !== isHostile && window.eventDispatcher) {
|
|
console.log(`⚔️ Emitting NPC_HOSTILE_CHANGED for ${npcId} (isHostile=${isHostile})`);
|
|
window.eventDispatcher.emit(CombatEvents.NPC_HOSTILE_CHANGED, {
|
|
npcId,
|
|
isHostile
|
|
});
|
|
} else if (wasHostile === isHostile) {
|
|
console.log(`⚔️ State unchanged for ${npcId} (already ${wasHostile}), skipping event`);
|
|
} else {
|
|
console.warn(`⚔️ Event dispatcher not found, cannot emit NPC_HOSTILE_CHANGED`);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function isNPCHostile(npcId) {
|
|
const state = npcHostileStates.get(npcId);
|
|
return state ? state.isHostile : false;
|
|
}
|
|
|
|
function getNPCHostileState(npcId) {
|
|
let state = npcHostileStates.get(npcId);
|
|
if (!state) {
|
|
state = createHostileState(npcId);
|
|
npcHostileStates.set(npcId, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function damageNPC(npcId, amount) {
|
|
const state = getNPCHostileState(npcId);
|
|
if (!state) return false;
|
|
|
|
if (state.isKO) {
|
|
console.log(`NPC ${npcId} already KO`);
|
|
return false;
|
|
}
|
|
|
|
const oldHP = state.currentHP;
|
|
state.currentHP = Math.max(0, state.currentHP - amount);
|
|
|
|
console.log(`NPC ${npcId} HP: ${oldHP} → ${state.currentHP}`);
|
|
|
|
// Check for KO
|
|
if (state.currentHP <= 0) {
|
|
state.isKO = true;
|
|
|
|
// Get NPC reference for death animation and server sync
|
|
const npc = window.npcManager?.getNPC(npcId);
|
|
const sprite = npc?._sprite || npc?.sprite;
|
|
|
|
// Play death animation and disable physics after it completes
|
|
if (sprite) {
|
|
// Disable collisions immediately so player can walk through
|
|
if (sprite.body) {
|
|
sprite.body.setVelocity(0, 0);
|
|
// Disable all collision checks but keep body enabled for animation
|
|
sprite.body.checkCollision.none = true;
|
|
console.log(`💀 Disabled collisions for ${npcId}, starting death animation`);
|
|
}
|
|
|
|
playNPCDeathAnimation(npcId, sprite);
|
|
|
|
// Disable physics body completely after death animation completes
|
|
// Use animationcomplete event to ensure all frames play
|
|
sprite.once('animationcomplete', (anim) => {
|
|
console.log(`💀 Animation complete event fired for ${npcId}, anim key: ${anim.key}`);
|
|
if (anim.key.includes('death') && sprite && sprite.body && !sprite.destroyed) {
|
|
sprite.body.enable = false; // Disable physics body entirely
|
|
console.log(`💀 Disabled physics body for ${npcId} after animation complete`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Drop any items the NPC was holding
|
|
dropNPCItems(npcId);
|
|
|
|
// Note: Item drops are synced to server in dropNPCItems via RoomStateSync
|
|
|
|
if (window.eventDispatcher) {
|
|
window.eventDispatcher.emit(CombatEvents.NPC_KO, { npcId });
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Play death animation for NPC
|
|
* @param {string} npcId - The NPC ID
|
|
* @param {Phaser.GameObjects.Sprite} sprite - The NPC sprite
|
|
*/
|
|
function playNPCDeathAnimation(npcId, sprite) {
|
|
if (!sprite || !sprite.scene) {
|
|
console.warn(`⚠️ Cannot play death animation - invalid sprite for ${npcId}`);
|
|
return;
|
|
}
|
|
|
|
// Get NPC's current facing direction
|
|
const direction = getNPCDirection(npcId, sprite);
|
|
|
|
// Build death animation key: npc-{npcId}-death-{direction}
|
|
const deathAnimKey = `npc-${npcId}-death-${direction}`;
|
|
|
|
if (sprite.scene.anims.exists(deathAnimKey)) {
|
|
// Stop any current animation first
|
|
if (sprite.anims.isPlaying) {
|
|
sprite.anims.stop();
|
|
}
|
|
|
|
// Store original origin for restoration
|
|
const originalOriginY = sprite.originY;
|
|
|
|
// Add animation update listener to progressively shift visual display downward
|
|
sprite.on('animationupdate', (anim, frame) => {
|
|
if (anim.key === deathAnimKey) {
|
|
// Calculate progress through animation (0 to 1)
|
|
const totalFrames = anim.getTotalFrames();
|
|
const currentFrame = frame.index;
|
|
const progress = currentFrame / totalFrames;
|
|
|
|
// Shift sprite's visual display downward by adjusting origin
|
|
// This moves the texture down without changing sprite.y (keeps depth constant)
|
|
// Decrease originY by ~0.5 to shift texture down by half sprite height (~40px for 80px sprite)
|
|
const originOffset = progress * 0.5;
|
|
sprite.setOrigin(sprite.originX, originalOriginY - originOffset);
|
|
}
|
|
});
|
|
|
|
// Clean up listener when animation completes
|
|
sprite.once('animationcomplete', (anim) => {
|
|
if (anim.key === deathAnimKey) {
|
|
sprite.off('animationupdate');
|
|
// Origin stays shifted - defeated body appears lower visually but sprite.y unchanged
|
|
// Depth remains constant, naturally rendering behind standing characters at same Y
|
|
}
|
|
});
|
|
|
|
sprite.play(deathAnimKey);
|
|
console.log(`💀 Playing NPC death animation: ${deathAnimKey}`);
|
|
} else {
|
|
console.warn(`⚠️ Death animation not found: ${deathAnimKey}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop items around a defeated NPC
|
|
* Items spawn at NPC location and are launched outward with physics
|
|
* They collide with walls, doors, and chairs so they stay in reach
|
|
* @param {string} npcId - The NPC that was defeated
|
|
*/
|
|
function dropNPCItems(npcId) {
|
|
const npc = window.npcManager?.getNPC(npcId);
|
|
const sprite = npc?._sprite || npc?.sprite;
|
|
if (!npc || !npc.itemsHeld || npc.itemsHeld.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Use the sprite we already have and find its room
|
|
if (!sprite) {
|
|
console.warn(`⚠️ Cannot drop items - no sprite found for NPC: ${npcId}`);
|
|
return;
|
|
}
|
|
|
|
// Find the NPC's room
|
|
let npcRoomId = npc.roomId;
|
|
|
|
if (!npcRoomId) {
|
|
console.warn(`Could not find room for NPC ${npcId}`);
|
|
return;
|
|
}
|
|
|
|
const room = window.rooms[npcRoomId];
|
|
const gameRef = window.game;
|
|
const itemCount = npc.itemsHeld.length;
|
|
const launchSpeed = 200; // pixels per second
|
|
|
|
npc.itemsHeld.forEach((item, index) => {
|
|
// Calculate angle around the NPC for each item
|
|
const angle = (index / itemCount) * Math.PI * 2;
|
|
|
|
// All items spawn at NPC center location
|
|
const spawnX = Math.round(sprite.x);
|
|
const spawnY = Math.round(sprite.y);
|
|
|
|
// Create actual Phaser sprite for the dropped item
|
|
// Try item.texture, then item.type, with fallback to 'key' if texture doesn't exist
|
|
let texture = item.texture || item.type || 'key';
|
|
|
|
console.log(`💧 Attempting to drop item: type="${item.type}", name="${item.name}", texture="${texture}"`);
|
|
|
|
// Safety check: verify texture exists, fallback to 'key' if not
|
|
if (!gameRef.textures.exists(texture)) {
|
|
console.warn(`⚠️ Texture '${texture}' not found for dropped item '${item.name}', using fallback 'key'`);
|
|
texture = 'key';
|
|
} else {
|
|
console.log(`✅ Texture '${texture}' exists for dropped item '${item.name}'`);
|
|
}
|
|
|
|
const spriteObj = gameRef.add.sprite(spawnX, spawnY, texture);
|
|
|
|
// Set origin to match standard object creation
|
|
spriteObj.setOrigin(0, 0);
|
|
|
|
// Create scenario data from the dropped item
|
|
const droppedItemData = {
|
|
...item,
|
|
type: item.type || 'dropped_item',
|
|
name: item.name || 'Item',
|
|
takeable: true,
|
|
active: true,
|
|
visible: true,
|
|
interactable: true
|
|
};
|
|
|
|
// DEBUG: Log key properties if this is a key
|
|
if (item.type === 'key') {
|
|
console.log(`🔑 Dropped key "${item.name}":`, {
|
|
source: 'npc-hostile.js dropNPCItems',
|
|
item_key_id: item.key_id,
|
|
item_keyPins: item.keyPins,
|
|
droppedItemData_key_id: droppedItemData.key_id,
|
|
droppedItemData_keyPins: droppedItemData.keyPins
|
|
});
|
|
}
|
|
|
|
// Apply scenario properties to sprite
|
|
spriteObj.scenarioData = droppedItemData;
|
|
spriteObj.interactable = true;
|
|
spriteObj.name = droppedItemData.name;
|
|
spriteObj.objectId = `dropped_${npcId}_${index}_${Date.now()}`;
|
|
spriteObj.takeable = true;
|
|
spriteObj.type = droppedItemData.type;
|
|
|
|
// Copy over all properties from the item
|
|
Object.keys(droppedItemData).forEach(key => {
|
|
spriteObj[key] = droppedItemData[key];
|
|
});
|
|
|
|
// IMPORTANT: Preserve texture information for inventory display
|
|
// Phaser sprites have a complex texture object, but inventory expects
|
|
// a simple object with a 'key' property (matching npc-game-bridge pattern)
|
|
// Store both the original texture reference and a simple texture key object
|
|
const phaserTexture = spriteObj.texture; // Preserve Phaser's texture object
|
|
spriteObj.texture = {
|
|
key: texture, // Use the resolved texture name for inventory
|
|
_phaserTexture: phaserTexture // Keep reference to original Phaser texture
|
|
};
|
|
|
|
console.log(`💧 Dropped item sprite texture set: key="${spriteObj.texture.key}", name="${spriteObj.name}"`);
|
|
|
|
// Make the sprite interactive
|
|
spriteObj.setInteractive({ useHandCursor: true });
|
|
|
|
// Set up physics body for collision
|
|
gameRef.physics.add.existing(spriteObj);
|
|
spriteObj.body.setSize(24, 24);
|
|
spriteObj.body.setOffset(4, 4);
|
|
spriteObj.body.setBounce(0.3); // Reduced bounce
|
|
spriteObj.body.setFriction(0.99, 0.99); // High friction to stop movement
|
|
spriteObj.body.setDrag(0.99); // Drag coefficient to slow velocity
|
|
|
|
// Launch item outward in the calculated angle
|
|
const velocityX = Math.cos(angle) * launchSpeed;
|
|
const velocityY = Math.sin(angle) * launchSpeed;
|
|
spriteObj.body.setVelocity(velocityX, velocityY);
|
|
|
|
// Set a timer to completely stop the item after a short time
|
|
const stopDelay = 800; // Stop after 0.8 seconds
|
|
gameRef.time.delayedCall(stopDelay, () => {
|
|
if (spriteObj && spriteObj.body) {
|
|
spriteObj.body.setVelocity(0, 0);
|
|
spriteObj.body.setAcceleration(0, 0);
|
|
}
|
|
});
|
|
|
|
// Set up collisions with walls
|
|
if (room.wallCollisionBoxes) {
|
|
room.wallCollisionBoxes.forEach(wallBox => {
|
|
if (wallBox.body) {
|
|
gameRef.physics.add.collider(spriteObj, wallBox);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set up collisions with closed doors
|
|
if (room.doorSprites) {
|
|
room.doorSprites.forEach(doorSprite => {
|
|
if (doorSprite.body && doorSprite.body.immovable) {
|
|
gameRef.physics.add.collider(spriteObj, doorSprite);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set up collisions with chairs and other immovable objects
|
|
if (room.objects) {
|
|
Object.values(room.objects).forEach(obj => {
|
|
if (obj !== spriteObj && obj.body && obj.body.immovable) {
|
|
gameRef.physics.add.collider(spriteObj, obj);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set depth using the existing depth calculation method
|
|
// depth = objectBottomY + 0.5
|
|
const objectBottomY = spriteObj.y + (spriteObj.height || 0);
|
|
const objectDepth = objectBottomY + 0.5;
|
|
spriteObj.setDepth(objectDepth);
|
|
|
|
// Update depth each frame to follow Y position
|
|
const originalUpdate = spriteObj.update?.bind(spriteObj);
|
|
spriteObj.update = function() {
|
|
if (originalUpdate) originalUpdate();
|
|
const newDepth = this.y + (this.height || 0) + 0.5;
|
|
this.setDepth(newDepth);
|
|
};
|
|
|
|
// Store in room.objects
|
|
room.objects[spriteObj.objectId] = spriteObj;
|
|
|
|
console.log(`💧 Dropped item ${droppedItemData.type} from ${npcId} at (${spawnX}, ${spawnY}), launching at angle ${(angle * 180 / Math.PI).toFixed(1)}°`);
|
|
|
|
// Sync dropped item to server for persistence
|
|
if (window.RoomStateSync) {
|
|
// Create item data for server (without Phaser-specific properties)
|
|
const itemForServer = {
|
|
id: spriteObj.objectId,
|
|
type: droppedItemData.type,
|
|
name: droppedItemData.name,
|
|
texture: texture,
|
|
x: spawnX,
|
|
y: spawnY,
|
|
takeable: true,
|
|
interactable: true,
|
|
scenarioData: droppedItemData
|
|
};
|
|
|
|
window.RoomStateSync.addItemToRoom(npcRoomId, itemForServer, {
|
|
npcId: npcId,
|
|
sourceType: 'npc_defeated'
|
|
}).catch(err => {
|
|
console.error('Failed to sync dropped item to server:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Clear the NPC's inventory
|
|
npc.itemsHeld = [];
|
|
}
|
|
|
|
function isNPCKO(npcId) {
|
|
const state = npcHostileStates.get(npcId);
|
|
return state ? state.isKO : false;
|
|
}
|