feat: Add NPC state management with server synchronization for KO and health changes

This commit is contained in:
Z. Cliffe Schreuders
2026-02-17 00:07:16 +00:00
parent e18e1d7228
commit dbd532fdce
6 changed files with 207 additions and 3 deletions

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
};