diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb index 2a91f2b..7c1e5b0 100644 --- a/app/controllers/break_escape/games_controller.rb +++ b/app/controllers/break_escape/games_controller.rb @@ -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'] diff --git a/app/models/break_escape/game.rb b/app/models/break_escape/game.rb index 0a5713a..d7fb8c6 100644 --- a/app/models/break_escape/game.rb +++ b/app/models/break_escape/game.rb @@ -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 diff --git a/public/break_escape/js/systems/npc-hostile.js b/public/break_escape/js/systems/npc-hostile.js index 3942a0f..e968ce2 100644 --- a/public/break_escape/js/systems/npc-hostile.js +++ b/public/break_escape/js/systems/npc-hostile.js @@ -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 diff --git a/public/break_escape/js/systems/npc-lazy-loader.js b/public/break_escape/js/systems/npc-lazy-loader.js index 20649b5..fa86a51 100644 --- a/public/break_escape/js/systems/npc-lazy-loader.js +++ b/public/break_escape/js/systems/npc-lazy-loader.js @@ -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); diff --git a/public/break_escape/js/systems/npc-sprites.js b/public/break_escape/js/systems/npc-sprites.js index 5f30b3c..a617b32 100644 --- a/public/break_escape/js/systems/npc-sprites.js +++ b/public/break_escape/js/systems/npc-sprites.js @@ -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; diff --git a/public/break_escape/js/systems/room-state-sync.js b/public/break_escape/js/systems/room-state-sync.js index 61b448a..e8ab7ef 100644 --- a/public/break_escape/js/systems/room-state-sync.js +++ b/public/break_escape/js/systems/room-state-sync.js @@ -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} 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 };