mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
feat: Add NPC state management with server synchronization for KO and health changes
This commit is contained in:
@@ -394,6 +394,16 @@ module BreakEscape
|
||||
|
||||
@game.update_object_state!(room_id, object_id, state_changes.to_unsafe_h)
|
||||
|
||||
when 'update_npc_state'
|
||||
npc_id = data[:npcId] || data['npcId']
|
||||
state_changes = data[:stateChanges] || data['stateChanges']
|
||||
|
||||
unless npc_id.present? && state_changes.present?
|
||||
return render json: { success: false, message: 'Invalid NPC state data' }, status: :bad_request
|
||||
end
|
||||
|
||||
@game.update_npc_state!(room_id, npc_id, state_changes.to_unsafe_h)
|
||||
|
||||
when 'move_npc'
|
||||
npc_id = data[:npcId] || data['npcId']
|
||||
from_room = data[:fromRoom] || data['fromRoom']
|
||||
|
||||
@@ -174,7 +174,7 @@ module BreakEscape
|
||||
# Add an item to a room (e.g., NPC drops item)
|
||||
def add_item_to_room!(room_id, item, source_data = {})
|
||||
player_state['room_states'] ||= {}
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npc_states' => {} }
|
||||
|
||||
# Validate item has required fields
|
||||
unless item.is_a?(Hash) && item['type'].present?
|
||||
@@ -206,7 +206,7 @@ module BreakEscape
|
||||
# Remove an item from a room (e.g., player picks up)
|
||||
def remove_item_from_room!(room_id, item_id)
|
||||
player_state['room_states'] ||= {}
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npc_states' => {} }
|
||||
|
||||
# Check if item exists in room (scenario or added)
|
||||
item_exists = item_in_room?(room_id, item_id)
|
||||
@@ -231,7 +231,7 @@ module BreakEscape
|
||||
# Update object state (e.g., container opened, light switched on)
|
||||
def update_object_state!(room_id, object_id, state_changes)
|
||||
player_state['room_states'] ||= {}
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npc_states' => {} }
|
||||
|
||||
# Validate object exists
|
||||
unless item_in_room?(room_id, object_id)
|
||||
@@ -248,6 +248,29 @@ module BreakEscape
|
||||
true
|
||||
end
|
||||
|
||||
# Update NPC state (e.g., defeated/KO, health changes)
|
||||
def update_npc_state!(room_id, npc_id, state_changes)
|
||||
player_state['room_states'] ||= {}
|
||||
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npc_states' => {} }
|
||||
|
||||
# Ensure npc_states key exists (for backwards compatibility with existing data)
|
||||
player_state['room_states'][room_id]['npc_states'] ||= {}
|
||||
|
||||
# Validate NPC exists in room
|
||||
unless npc_in_room?(npc_id, room_id)
|
||||
Rails.logger.warn "[BreakEscape] NPC #{npc_id} not found in room #{room_id}"
|
||||
return false
|
||||
end
|
||||
|
||||
# Merge state changes
|
||||
player_state['room_states'][room_id]['npc_states'][npc_id] ||= {}
|
||||
player_state['room_states'][room_id]['npc_states'][npc_id].merge!(state_changes)
|
||||
|
||||
save!
|
||||
Rails.logger.info "[BreakEscape] Updated NPC #{npc_id} state in room #{room_id}: #{state_changes.inspect}"
|
||||
true
|
||||
end
|
||||
|
||||
# Move NPC between rooms
|
||||
def move_npc_to_room!(npc_id, from_room_id, to_room_id)
|
||||
player_state['room_states'] ||= {}
|
||||
@@ -345,6 +368,45 @@ module BreakEscape
|
||||
nil
|
||||
end
|
||||
|
||||
# Check if an item is already in the player's inventory
|
||||
# Matches by type, id, or name (similar to container filtering logic)
|
||||
def item_in_inventory?(item, inventory)
|
||||
return false if inventory.blank? || item.blank?
|
||||
|
||||
# Normalize item data (handle both string and symbol keys)
|
||||
item_type = item['type'] || item[:type]
|
||||
item_id = item['key_id'] || item[:key_id] || item['id'] || item[:id]
|
||||
item_name = item['name'] || item[:name]
|
||||
|
||||
inventory.any? do |inv_item|
|
||||
# Inventory items are stored as flat objects (not nested in scenarioData)
|
||||
# Handle both string and symbol keys
|
||||
inv_type = inv_item['type'] || inv_item[:type]
|
||||
inv_id = inv_item['key_id'] || inv_item[:key_id] || inv_item['id'] || inv_item[:id]
|
||||
inv_name = inv_item['name'] || inv_item[:name]
|
||||
|
||||
# Must match type
|
||||
next false unless inv_type == item_type
|
||||
|
||||
# If both have IDs, match by ID (most specific)
|
||||
if item_id.present? && inv_id.present?
|
||||
return true if inv_id.to_s == item_id.to_s
|
||||
end
|
||||
|
||||
# If both have names, match by name (fallback if no ID match)
|
||||
if item_name.present? && inv_name.present?
|
||||
return true if inv_name.to_s == item_name.to_s
|
||||
end
|
||||
|
||||
# If item has no ID or name, match by type only (less specific, but works for generic items)
|
||||
if item_id.blank? && item_name.blank?
|
||||
return true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
# Health management
|
||||
@@ -432,6 +494,21 @@ module BreakEscape
|
||||
room['npcs'] ||= []
|
||||
room['npcs'].concat(room_state['npcs_added'])
|
||||
end
|
||||
|
||||
# Apply NPC state changes
|
||||
if room_state['npc_states'].present?
|
||||
room['npcs']&.each do |npc|
|
||||
if room_state['npc_states'][npc['id']]
|
||||
npc.merge!(room_state['npc_states'][npc['id']])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Filter out items that are already in player's inventory
|
||||
# This prevents items from appearing both in room and inventory after pickup
|
||||
if player_state['inventory'].present? && room['objects'].present?
|
||||
room['objects'].reject! { |obj| item_in_inventory?(obj, player_state['inventory']) }
|
||||
end
|
||||
end
|
||||
|
||||
# Unlock validation
|
||||
|
||||
@@ -102,6 +102,16 @@ function damageNPC(npcId, amount) {
|
||||
const npc = window.npcManager?.getNPC(npcId);
|
||||
const sprite = npc?._sprite || npc?.sprite;
|
||||
|
||||
// Sync NPC KO state to server for persistence
|
||||
if (window.RoomStateSync && npc?.roomId) {
|
||||
window.RoomStateSync.updateNpcState(npc.roomId, npcId, {
|
||||
isKO: true,
|
||||
currentHP: 0
|
||||
}).catch(err => {
|
||||
console.error('Failed to sync NPC KO state to server:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Play death animation and disable physics after it completes
|
||||
if (sprite) {
|
||||
// Disable collisions immediately so player can walk through
|
||||
|
||||
@@ -57,6 +57,19 @@ export default class NPCLazyLoader {
|
||||
// We use the second form - passing the full object
|
||||
this.npcManager.registerNPC(npcDef);
|
||||
console.log(`✅ Registered NPC: ${npcDef.id} (${npcDef.npcType}) in room ${roomId}`);
|
||||
|
||||
// Check if NPC was defeated in a previous session (isKO state persisted)
|
||||
if (npcDef.isKO && window.npcHostileSystem) {
|
||||
console.log(`💀 NPC ${npcDef.id} has persisted KO state from server - restoring hostile state`);
|
||||
|
||||
// Restore hostile state with KO status
|
||||
const npcState = window.npcHostileSystem.getState(npcDef.id);
|
||||
npcState.isKO = true;
|
||||
npcState.currentHP = npcDef.currentHP || 0;
|
||||
npcState.isHostile = true; // Mark as hostile so behavior system knows it's combat-related
|
||||
|
||||
// Note: When sprite is created, it will check isKO and render death animation
|
||||
}
|
||||
}
|
||||
|
||||
this.loadedRooms.add(roomId);
|
||||
|
||||
@@ -136,6 +136,42 @@ export function createNPCSprite(scene, npc, roomData) {
|
||||
// Store reference in NPC data for later access
|
||||
npc._sprite = sprite;
|
||||
|
||||
// Check if NPC was previously defeated (KO state persisted from server)
|
||||
if (window.npcHostileSystem && window.npcHostileSystem.isNPCKO(npc.id)) {
|
||||
console.log(`💀 NPC ${npc.id} is KO'd - disabling sprite and playing death animation`);
|
||||
|
||||
// Disable collision immediately
|
||||
if (sprite.body) {
|
||||
sprite.body.setVelocity(0, 0);
|
||||
sprite.body.checkCollision.none = true;
|
||||
sprite.body.enable = false;
|
||||
}
|
||||
|
||||
// Play death animation if available
|
||||
const direction = getNPCDirection(npc.id, sprite);
|
||||
const deathAnimKey = `npc-${npc.id}-death-${direction}`;
|
||||
|
||||
if (scene.anims.exists(deathAnimKey)) {
|
||||
// Stop current animation
|
||||
if (sprite.anims.isPlaying) {
|
||||
sprite.anims.stop();
|
||||
}
|
||||
|
||||
// Play final frame of death animation (skip to end)
|
||||
sprite.play(deathAnimKey);
|
||||
// Jump to last frame to show defeated state
|
||||
const anim = scene.anims.get(deathAnimKey);
|
||||
if (anim && anim.frames && anim.frames.length > 0) {
|
||||
sprite.anims.setProgress(1); // Jump to final frame
|
||||
}
|
||||
console.log(`💀 NPC ${npc.id} rendered with death animation final frame`);
|
||||
} else {
|
||||
// No death animation - just hide the sprite
|
||||
sprite.setVisible(false);
|
||||
console.log(`💀 NPC ${npc.id} hidden (no death animation found)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ NPC sprite created: ${npc.id} at (${worldPos.x}, ${worldPos.y})`);
|
||||
|
||||
return sprite;
|
||||
|
||||
@@ -162,6 +162,61 @@ export async function updateObjectState(roomId, objectId, stateChanges) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update NPC state in a room (e.g., defeated/KO, health changes)
|
||||
* @param {string} roomId - Room ID
|
||||
* @param {string} npcId - NPC ID
|
||||
* @param {object} stateChanges - State properties to update
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
export async function updateNpcState(roomId, npcId, stateChanges) {
|
||||
const gameId = window.breakEscapeConfig?.gameId;
|
||||
if (!gameId) {
|
||||
console.error('Cannot sync room state: gameId not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/break_escape/games/${gameId}/update_room`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roomId: roomId,
|
||||
actionType: 'update_npc_state',
|
||||
data: {
|
||||
npcId: npcId,
|
||||
stateChanges: stateChanges
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ Synced NPC state update in room ${roomId}:`, npcId, stateChanges);
|
||||
|
||||
// Update local NPC state if room is loaded
|
||||
if (window.npcManager) {
|
||||
const npc = window.npcManager.getNPC(npcId);
|
||||
if (npc) {
|
||||
Object.assign(npc, stateChanges);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`❌ Failed to sync NPC state: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing NPC state:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move NPC between rooms
|
||||
* @param {string} npcId - NPC ID
|
||||
@@ -242,6 +297,8 @@ export async function batchUpdateRoomState(updates) {
|
||||
return removeItemFromRoom(update.roomId, update.itemId);
|
||||
case 'update_object':
|
||||
return updateObjectState(update.roomId, update.objectId, update.stateChanges);
|
||||
case 'update_npc':
|
||||
return updateNpcState(update.roomId, update.npcId, update.stateChanges);
|
||||
case 'move_npc':
|
||||
return moveNpcToRoom(update.npcId, update.fromRoomId, update.toRoomId);
|
||||
default:
|
||||
@@ -272,6 +329,7 @@ window.RoomStateSync = {
|
||||
addItemToRoom,
|
||||
removeItemFromRoom,
|
||||
updateObjectState,
|
||||
updateNpcState,
|
||||
moveNpcToRoom,
|
||||
batchUpdateRoomState
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user