diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb index b82f2bd..fb48cbe 100644 --- a/app/controllers/break_escape/games_controller.rb +++ b/app/controllers/break_escape/games_controller.rb @@ -940,6 +940,18 @@ module BreakEscape end def find_accessible_item(item_type, item_id, item_name) + # Priority 0: Dynamically-added items (e.g., dropped by defeated NPCs). + # These were already security-validated by add_item_to_room! so they are always allowed. + @game.player_state['room_states']&.each do |room_id, room_state| + room_state['objects_added']&.each do |added_obj| + next unless added_obj['type'] == item_type + next unless added_obj['key_id'] == item_id || added_obj['id'] == item_id || + added_obj['name'] == item_name || added_obj['name'] == item_id || + item_id.nil? + return { item: added_obj, location: { type: 'room', room_id: room_id } } + end + end + # Priority 1: Items in unlocked rooms (most accessible) @game.scenario_data['rooms'].each do |room_id, room_data| if room_data['locked'] == false || @game.player_state['unlockedRooms'].include?(room_id) diff --git a/public/break_escape/js/core/game.js b/public/break_escape/js/core/game.js index 8e38c83..d34966b 100644 --- a/public/break_escape/js/core/game.js +++ b/public/break_escape/js/core/game.js @@ -1,5 +1,5 @@ import { initializeRooms, calculateWorldBounds, calculateRoomPositions, createRoom, revealRoom, updatePlayerRoom, rooms } from './rooms.js?v=16'; -import { createPlayer, updatePlayerMovement, movePlayerToPoint, player } from './player.js?v=7'; +import { createPlayer, updatePlayerMovement, movePlayerToPoint, facePlayerToward, player } from './player.js?v=8'; import { initializePathfinder } from './pathfinding.js?v=7'; import { initializeInventory, processInitialInventoryItems } from '../systems/inventory.js?v=8'; import { checkObjectInteractions, setGameInstance, isObjectInInteractionRange } from '../systems/interactions.js?v=28'; @@ -877,38 +877,29 @@ export async function create() { // Check for NPC sprites at the clicked position first const npcAtPosition = findNPCAtPosition(worldX, worldY); if (npcAtPosition) { - // Try to interact with the NPC - if (window.tryInteractWithNPC) { - const interactionSuccessful = window.tryInteractWithNPC(npcAtPosition); - - if (interactionSuccessful) { - // Interaction was successful (NPC was in range) - return; // Exit early after handling the interaction - } else { - // NPC was out of range - treat click as a movement request - // Calculate floor-level destination along direct line from player to NPC - // Account for sprite padding (16px for atlas sprites) - const spriteCenterToBottom = npcAtPosition.height * (1 - (npcAtPosition.originY || 0.5)); - const paddingOffset = npcAtPosition.isAtlas ? SPRITE_PADDING_BOTTOM_ATLAS : SPRITE_PADDING_BOTTOM_LEGACY; - const npcBottomY = npcAtPosition.y + spriteCenterToBottom - paddingOffset; - - // Calculate direction from player to NPC - const dx = npcAtPosition.x - player.x; - const dy = npcBottomY - player.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > 0) { - // Normalize direction and stop short by offset - const stopShortOffset = TILE_SIZE * 0.75; // Stop 24 pixels short (3/4 tile) - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; - const targetX = npcAtPosition.x - normalizedDx * stopShortOffset; - const targetY = npcBottomY - normalizedDy * stopShortOffset; - movePlayerToPoint(targetX, targetY); - } - return; + if (isObjectInInteractionRange(npcAtPosition)) { + // NPC is in range - face toward them then interact. + facePlayerToward(npcAtPosition.x, npcAtPosition.y); + if (window.tryInteractWithNPC) { + window.tryInteractWithNPC(npcAtPosition); + } + } else { + // NPC is out of range - move toward them, stopping just short. + const spriteCenterToBottom = npcAtPosition.height * (1 - (npcAtPosition.originY || 0.5)); + const paddingOffset = npcAtPosition.isAtlas ? SPRITE_PADDING_BOTTOM_ATLAS : SPRITE_PADDING_BOTTOM_LEGACY; + const npcBottomY = npcAtPosition.y + spriteCenterToBottom - paddingOffset; + const dx = npcAtPosition.x - player.x; + const dy = npcBottomY - player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > 0) { + const stopShortOffset = TILE_SIZE * 0.75; + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + movePlayerToPoint(npcAtPosition.x - normalizedDx * stopShortOffset, + npcBottomY - normalizedDy * stopShortOffset); } } + return; } // Check for objects at the clicked position @@ -920,9 +911,13 @@ export async function create() { for (const obj of objectsAtPosition) { if (obj.interactable && window.handleObjectInteraction) { if (isObjectInInteractionRange(obj)) { - // Object is in range - interact directly. + // Object is in range - face toward it then interact directly. // Click always targets the clicked object; no direction-based selection. + facePlayerToward(obj.x, obj.y); window.handleObjectInteraction(obj); + } else if (obj.isSwivelChair) { + // Chairs: move onto the clicked position (player sits/stands at the chair). + movePlayerToPoint(worldX, worldY); } else { // Object is out of range - move toward it, stopping just short. const objBottomY = obj.y + obj.height * (1 - (obj.originY || 0)); diff --git a/public/break_escape/js/core/player.js b/public/break_escape/js/core/player.js index 872a827..8b0a426 100644 --- a/public/break_escape/js/core/player.js +++ b/public/break_escape/js/core/player.js @@ -747,6 +747,41 @@ export function movePlayerToPoint(x, y) { } } +/** + * Turn the player to face a world position, updating direction and idle animation. + * Call this before triggering a click-based interaction so the player visually + * faces the object/NPC they are acting on. + */ +export function facePlayerToward(targetX, targetY) { + if (!player) return; + + const dx = targetX - player.x; + const dy = targetY - player.y; + const absX = Math.abs(dx); + const absY = Math.abs(dy); + + let direction; + if (absX > absY * 2) { + direction = dx > 0 ? 'right' : 'left'; + } else if (absY > absX * 2) { + direction = dy > 0 ? 'down' : 'up'; + } else { + direction = dy > 0 ? (dx > 0 ? 'down-right' : 'down-left') + : (dx > 0 ? 'up-right' : 'up-left'); + } + + player.direction = direction; + player.lastDirection = direction; + + // Play idle animation for the new direction (handles atlas vs legacy sprite mapping) + const animDir = getAnimationKey(direction); + const currentAnim = player.anims.currentAnim?.key || ''; + if (!currentAnim.includes('punch') && !currentAnim.includes('jab') && + !currentAnim.includes('death') && !currentAnim.includes('taking-punch')) { + player.anims.play(`idle-${animDir}`, true); + } +} + function updatePlayerDepth(x, y) { // Get the bottom of the player sprite, accounting for padding // Atlas sprites (80x80) have 16px padding at bottom, legacy sprites (64x64) have minimal padding diff --git a/public/break_escape/js/systems/interactions.js b/public/break_escape/js/systems/interactions.js index 36f0476..edad16a 100644 --- a/public/break_escape/js/systems/interactions.js +++ b/public/break_escape/js/systems/interactions.js @@ -611,8 +611,10 @@ export function handleObjectInteraction(sprite) { } // Handle keycard cloning (when clicked from inventory) - if (sprite.scenarioData.type === 'keycard') { - console.log('KEYCARD INTERACTION - checking for cloner'); + // Only intercept if the card is already in inventory (takeable === false). + // If takeable is true the card is still in the world and should be picked up normally. + if (sprite.scenarioData.type === 'keycard' && sprite.scenarioData.takeable === false) { + console.log('KEYCARD INTERACTION (inventory) - checking for cloner'); // Check if player has RFID cloner const hasCloner = window.inventory.items.some(item => diff --git a/scenarios/m01_first_contact/scenario.json.erb b/scenarios/m01_first_contact/scenario.json.erb index c61bc0d..81de3d8 100644 --- a/scenarios/m01_first_contact/scenario.json.erb +++ b/scenarios/m01_first_contact/scenario.json.erb @@ -877,7 +877,7 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A "type": "text_file", "id": "contingency_files", "name": "CONTINGENCY - IT Audit Response", - "takeable": true, + "takeable": false, "readable": true, "text": "═══════════════════════════════════════════════════════════\n CONTINGENCY PLAN: IT AUDIT RESPONSE\n [ACTIVATE IF COMPROMISED]\n═══════════════════════════════════════════════════════════\n\nIf audit discovers anomalies, activate CONTINGENCY.\n\nIT Manager Kevin Park becomes the fall guy.\nHis access patterns can be retroactively modified.\nHis termination provides closure for the company and\nends investigation.\n\nPREPARED EVIDENCE:\n- Fake security logs showing Kevin accessing servers at odd hours\n- Forged email from Kevin to 'unknown external party'\n- Timeline framing Kevin as source of data breach\n\nKevin helped you. Gave you access. Trusted you.\nAnd Derek is going to destroy his career—maybe his life—\nto cover ENTROPY's tracks.\n\n[This file triggers a moral choice when collected]", "observations": "Derek's plan to frame Kevin for everything"