Files
BreakEscape/js/systems/interactions.js
Z. Cliffe Schreuders 660105421a Add Hostile NPC mode
Fix NPC interaction and event handling issues

- Added a visual problem-solution summary for debugging NPC event handling.
- Resolved cooldown bug in NPCManager by implementing explicit null/undefined checks.
- Modified PersonChatMinigame to prioritize event parameters over state restoration.
- Updated security guard dialogue in Ink scenarios to improve interaction flow.
- Adjusted vault key parameters in npc-patrol-lockpick.json for consistency.
- Changed inventory stylesheet references to hud.css in test HTML files for better organization.

feat(combat): Integrate chair kicking with punch mechanic

Update chair interaction to use the punch system instead of direct kicking:

**Changes to interactions.js:**
- Modified swivel chair interaction to trigger player punch instead of
  directly applying kick velocity
- Simplified chair interaction handler to just call playerCombat.punch()

**Changes to player-combat.js:**
- Extended checkForHits() to detect chairs in punch range and direction
- Added kickChair() method that applies the same velocity calculation:
  - Calculates direction from player to chair
  - Applies 1200 px/s kick force in that direction
  - Triggers spin direction calculation for visual rotation
  - Adds visual feedback (flash chair, light screen shake)
- Chairs now respond to punch AOE damage like hostile NPCs

Now clicking a chair or pressing 'E' near it triggers a punch, and if the
chair is in punch range and facing direction, it gets kicked with the
original velocity physics. Multiple chairs can be kicked with one punch.

feat(combat): Implement hostile NPC behavior and final integration (Phase 6-7)

Complete hostile NPC combat system with chase behavior and integration:

**Phase 6: Hostile NPC Behavior**
- Modified npc-behavior.js determineState() to check hostile state from npcHostileSystem
- Implemented updateHostileBehavior() with chase and attack logic:
  - NPCs chase player when hostile and in aggro range
  - NPCs stop and attack when in attack range
  - NPCs use directional movement with proper animations
  - Integration with npcCombat system for attack attempts
- Added KO state check to prevent KO'd NPCs from acting

**Phase 7: Final Integration**
- Modified player.js to disable movement when player is KO
- Added visual KO effect (50% alpha) to NPC sprites in npc-hostile.js
- Connected all combat systems end-to-end:
  - Ink dialogue → hostile tag → hostile state → chase behavior → combat
  - Player interaction → punch → NPC damage → KO → visual feedback
  - NPC chase → attack → player damage → HP UI → game over

Full combat loop now functional: hostile NPCs chase and attack player,
player can punch hostile NPCs, complete visual/audio feedback, game over on KO.

feat(combat): Add feedback, UI, and combat mechanics (Phase 2-5)

Implement comprehensive combat feedback, UI, and mechanics:

**Phase 2: Enhanced Feedback Systems**
- damage-numbers.js: Floating damage numbers with object pooling
- screen-effects.js: Screen flash and shake for combat feedback
- sprite-effects.js: Sprite tinting, flashing, and visual effects
- attack-telegraph.js: Visual indicators for incoming NPC attacks

**Phase 3: UI Components**
- health-ui.js: Player health display as hearts (5 hearts, shows when damaged)
- npc-health-bars.js: Health bars above hostile NPCs with color coding
- game-over-screen.js: KO screen with restart/main menu options

**Phase 4-5: Combat Mechanics**
- player-combat.js: Player punch system with AOE directional damage
- npc-combat.js: NPC attack system with telegraph and cooldowns
- Modified interactions.js to trigger punch on hostile NPC interaction
- Integrated all systems into game.js create() and update() loops

Combat now functional with complete visual/audio feedback pipeline.
Player can punch hostile NPCs, NPCs can attack player, health tracking works.

feat(combat): Add hostile NPC system foundation (Phase 0-1)

Implement core hostile NPC combat system infrastructure:

- Add #hostile tag handler to chat-helpers.js for Ink integration
- Fix security-guard.ink to use proper hub pattern with -> hub instead of -> END
- Add #hostile:security_guard tags to hostile conversation paths
- Create combat configuration system (combat-config.js)
- Create combat event constants (combat-events.js)
- Implement player health tracking system with HP and KO state
- Implement NPC hostile state management with HP tracking
- Add combat debug utilities for testing
- Add error handling utilities for validation
- Integrate combat systems into game.js create() method
- Create test-hostile.ink for testing hostile tag system

This establishes the foundation for hostile NPC behavior, allowing NPCs to
become hostile through Ink dialogue and tracking health for both player and NPCs.

docs(npc): Apply codebase-verified corrections to hostile NPC plans

Apply critical corrections based on actual codebase verification:

CORRECTIONS.md (Updated):
-  Confirms #exit_conversation tag ALREADY IMPLEMENTED
  * Location: person-chat-minigame.js line 537
  * No handler needed in chat-helpers.js
-  Hostile tag still needs implementation in chat-helpers.js
- Provides exact code for hostile tag handler
- Clarifies tag format: #hostile:npcId or #hostile (uses current NPC)
- Updated action items to reflect what's already working

INTEGRATION_UPDATES.md (New):
- Comprehensive correction document
- Issue 1 Corrected: Exit conversation already works
- Issue 6 Corrected: Punch mechanics are interaction-based with AOE
- Details interaction-based punch targeting:
  * Player clicks hostile NPC OR presses 'E' nearby
  * Punch animation plays in facing direction
  * Damage applies to ALL NPCs in range + direction (AOE)
  * Can hit multiple enemies if grouped (strategic gameplay)
- Provides complete implementation examples
- Removes complexity of target selection systems
- Uses existing interaction patterns

quick_start.md (Updated):
- Removed exit_conversation handler (already exists)
- Updated hostile tag handler code
- Added punch mechanics design section
- Clarified interaction-based targeting
- Added troubleshooting for exit_conversation

Key Findings:
 Exit conversation tag works out of the box
 Punch targeting uses existing interaction system (simpler!)
 AOE punch adds strategic depth without complexity
 Only ONE critical task remains: Add hostile tag to chat-helpers.js

Impact:
- Less work required (don't need exit_conversation handler)
- Simpler implementation (use existing interaction patterns)
- Better gameplay (AOE punches, directional attacks)
- Clear path forward with exact code examples

docs(npc): Add critical corrections and codebase integration review

Add comprehensive review of hostile NPC plans against actual codebase:

CORRECTIONS.md:
- Identifies critical Ink pattern error (-> END vs -> hub)
- Documents correct hub-based conversation pattern
- Provides corrected examples for all Ink files
- Explains why -> hub is required after #exit_conversation

FORMAT_REVIEW.md:
- Validates JSON scenario format against existing scenarios
- Reviews NPC object structure and required fields
- Documents correct Ink hub pattern from helper-npc.ink
- Proposes hostile configuration object for NPC customization
- Provides complete format reference and checklists

review2/integration_review.md:
- Comprehensive codebase analysis by Explore agent
- Identifies 2 critical blockers requiring immediate attention:
  * Missing tag handlers for #hostile and #exit_conversation
  * Incorrect Ink pattern (-> END) in planning documents
- Documents 4 important integration differences:
  * Initialization in game.js not main.js
  * Event dispatcher already exists (window.eventDispatcher)
  * Room transition behavior needs design decision
  * Multi-hostile NPC targeting needs design decision
- Confirms 8 systems are fully compatible with plan
- Provides existing code patterns to follow
- Corrects integration sequence

review2/quick_start.md:
- Step-by-step guide for Phase 0-1 implementation
- Includes complete code examples for critical systems
- Browser console test procedures
- Common issues and solutions
- Success criteria checklist

Key Findings:
 90% compatible with existing codebase
 Must add tag handlers to chat-helpers.js before implementation
 Must fix all Ink examples to use -> hub not -> END
⚠️ Should follow game.js initialization pattern not main.js
⚠️ Should use existing window.eventDispatcher
⚠️ Need design decisions on room transitions and multi-targeting

All critical issues documented with solutions ready.
Implementation can proceed with high confidence after corrections applied.

docs(npc): Add comprehensive planning documents for hostile NPC system

Add detailed implementation plans for hostile NPC feature including:
- Complete implementation plan with phase-by-phase breakdown
- Architecture overview with system diagrams and data flows
- Detailed TODO list with 200+ actionable tasks
- Phase 0 foundation with design decisions and base components
- Enhanced combat feedback implementation guide
- Implementation roadmap with 6-day schedule

Add comprehensive review documents:
- Implementation review with risk assessment and recommendations
- Technical review analyzing code patterns and best practices
- UX review covering player experience and game feel

Key features planned:
- NPC hostile state triggered via Ink tags
- Player health system with heart-based UI
- NPC health bars and combat mechanics
- Punch combat for both player and NPCs
- Strong visual/audio feedback for combat
- Game over system and KO states
- Attack telegraphing for fairness
- Enhanced NPC chase behavior with LOS
- Debug utilities and error handling
- Comprehensive testing strategy
2025-11-14 19:47:54 +00:00

1021 lines
40 KiB
JavaScript

// Object interaction system
import { INTERACTION_RANGE, INTERACTION_RANGE_SQ, INTERACTION_CHECK_INTERVAL } from '../utils/constants.js?v=8';
import { rooms } from '../core/rooms.js?v=16';
import { handleUnlock } from './unlock-system.js';
import { handleDoorInteraction } from './doors.js';
import { collectFingerprint, handleBiometricScan } from './biometrics.js';
import { addToInventory, removeFromInventory, createItemIdentifier } from './inventory.js';
import { playUISound, playGameSound } from './ui-sounds.js?v=1';
let gameRef = null;
export function setGameInstance(gameInstance) {
gameRef = gameInstance;
}
// Helper function to calculate interaction distance with direction-based offset
// Extends reach from the edge of the player sprite in the direction the player is facing
function getInteractionDistance(playerSprite, targetX, targetY) {
const playerDirection = playerSprite.direction || 'down';
const SPRITE_HALF_WIDTH = 32; // 64px sprite / 2
const SPRITE_HALF_HEIGHT = 32; // 64px sprite / 2
const SPRITE_QUARTER_WIDTH = 16; // 64px sprite / 4 (for right/left)
const SPRITE_QUARTER_HEIGHT = 16; // 64px sprite / 4 (for down)
// Calculate offset point based on player direction
let offsetX = 0;
let offsetY = 0;
switch(playerDirection) {
case 'up':
offsetY = -SPRITE_HALF_HEIGHT;
break;
case 'down':
offsetY = SPRITE_QUARTER_HEIGHT;
break;
case 'left':
offsetX = -SPRITE_QUARTER_WIDTH;
break;
case 'right':
offsetX = SPRITE_QUARTER_WIDTH;
break;
case 'up-left':
offsetX = -SPRITE_HALF_WIDTH;
offsetY = -SPRITE_HALF_HEIGHT;
break;
case 'up-right':
offsetX = SPRITE_HALF_WIDTH;
offsetY = -SPRITE_HALF_HEIGHT;
break;
case 'down-left':
offsetX = -SPRITE_QUARTER_WIDTH;
offsetY = SPRITE_QUARTER_HEIGHT;
break;
case 'down-right':
offsetX = SPRITE_QUARTER_WIDTH;
offsetY = SPRITE_QUARTER_HEIGHT;
break;
}
// Measure from the offset point (edge of player sprite in facing direction)
const measureX = playerSprite.x + offsetX;
const measureY = playerSprite.y + offsetY;
const dx = targetX - measureX;
const dy = targetY - measureY;
return dx * dx + dy * dy; // Return squared distance for performance
}
// Update NPC talk icon positions every frame (even when not checking interactions)
function updateNPCTalkIcons() {
// Iterate through all rooms and update icon positions for visible icons
Object.values(rooms).forEach(room => {
if (room.npcSprites) {
room.npcSprites.forEach(sprite => {
if (sprite.interactionIndicator && sprite.interactionIndicator.visible) {
const iconX = Math.round(sprite.x + 5);
const iconY = Math.round(sprite.y - 38);
sprite.interactionIndicator.setPosition(iconX, iconY);
}
});
}
});
}
export function checkObjectInteractions() {
// Update NPC talk icons every frame to follow moving NPCs
updateNPCTalkIcons();
// Skip if not enough time has passed since last check
const currentTime = performance.now();
if (this.lastInteractionCheck &&
currentTime - this.lastInteractionCheck < INTERACTION_CHECK_INTERVAL) {
return;
}
this.lastInteractionCheck = currentTime;
const player = window.player;
if (!player) {
return; // Player not created yet
}
// We'll measure distance from the closest edge of the player sprite
const px = player.x;
const py = player.y;
// Get viewport bounds for performance optimization
const camera = gameRef ? gameRef.cameras.main : null;
const margin = INTERACTION_RANGE * 2; // Larger margin to catch more objects
const viewBounds = camera ? {
left: camera.scrollX - margin,
right: camera.scrollX + camera.width + margin,
top: camera.scrollY - margin,
bottom: camera.scrollY + camera.height + margin
} : null;
// Check ALL objects in ALL rooms, not just current room
Object.entries(rooms).forEach(([roomId, room]) => {
if (!room.objects) return;
Object.values(room.objects).forEach(obj => {
// Skip inactive objects
if (!obj.active) {
return;
}
// Skip non-interactable objects (only highlight scenario items)
if (!obj.interactable) {
// Clear highlight if object was previously highlighted
if (obj.isHighlighted) {
obj.isHighlighted = false;
obj.clearTint();
// Clean up interaction sprite if exists
if (obj.interactionIndicator) {
obj.interactionIndicator.destroy();
delete obj.interactionIndicator;
}
}
return;
}
// Skip highlighting for objects marked with noInteractionHighlight (like swivel chairs)
if (obj.noInteractionHighlight) {
return;
}
// Skip objects outside viewport for performance (if viewport bounds available)
if (viewBounds && (
obj.x < viewBounds.left ||
obj.x > viewBounds.right ||
obj.y < viewBounds.top ||
obj.y > viewBounds.bottom)) {
// Clear highlight if object is outside viewport
if (obj.isHighlighted) {
obj.isHighlighted = false;
obj.clearTint();
// Clean up interaction sprite if exists
if (obj.interactionIndicator) {
obj.interactionIndicator.destroy();
delete obj.interactionIndicator;
}
}
return;
}
// Use squared distance for performance
const distanceSq = getInteractionDistance(player, obj.x, obj.y);
if (distanceSq <= INTERACTION_RANGE_SQ) {
if (!obj.isHighlighted) {
obj.isHighlighted = true;
// Only apply tint if this is a sprite (has setTint method)
if (obj.setTint && typeof obj.setTint === 'function') {
obj.setTint(0x4da6ff); // Blue tint for interactable objects
}
// Add interaction indicator sprite
addInteractionIndicator(obj);
}
} else if (obj.isHighlighted) {
obj.isHighlighted = false;
// Only clear tint if this is a sprite
if (obj.clearTint && typeof obj.clearTint === 'function') {
obj.clearTint();
}
// Clean up interaction sprite if exists
if (obj.interactionIndicator) {
obj.interactionIndicator.destroy();
delete obj.interactionIndicator;
}
}
});
// Also check door sprites
if (room.doorSprites) {
Object.values(room.doorSprites).forEach(door => {
// Skip inactive or non-locked doors
if (!door.active || !door.doorProperties || !door.doorProperties.locked) {
// Clear highlight if door was previously highlighted
if (door.isHighlighted) {
door.isHighlighted = false;
door.clearTint();
// Clean up interaction sprite if exists
if (door.interactionIndicator) {
door.interactionIndicator.destroy();
delete door.interactionIndicator;
}
}
return;
}
// Skip doors outside viewport for performance (if viewport bounds available)
if (viewBounds && (
door.x < viewBounds.left ||
door.x > viewBounds.right ||
door.y < viewBounds.top ||
door.y > viewBounds.bottom)) {
// Clear highlight if door is outside viewport
if (door.isHighlighted) {
door.isHighlighted = false;
door.clearTint();
// Clean up interaction sprite if exists
if (door.interactionIndicator) {
door.interactionIndicator.destroy();
delete door.interactionIndicator;
}
}
return;
}
// Use squared distance for performance
const distanceSq = getInteractionDistance(player, door.x, door.y);
if (distanceSq <= INTERACTION_RANGE_SQ) {
if (!door.isHighlighted) {
door.isHighlighted = true;
door.setTint(0x4da6ff); // Blue tint for locked doors
// Add interaction indicator sprite for doors
addInteractionIndicator(door);
}
} else if (door.isHighlighted) {
door.isHighlighted = false;
door.clearTint();
// Clean up interaction sprite if exists
if (door.interactionIndicator) {
door.interactionIndicator.destroy();
delete door.interactionIndicator;
}
}
});
}
// Also check NPC sprites
if (room.npcSprites) {
room.npcSprites.forEach(sprite => {
// NPCs should always be interactable when present
if (!sprite.active) {
// Clear highlight if sprite was previously highlighted
if (sprite.isHighlighted) {
sprite.isHighlighted = false;
sprite.clearTint();
// Clean up interaction sprite if exists
if (sprite.interactionIndicator) {
sprite.interactionIndicator.destroy();
delete sprite.interactionIndicator;
}
}
return;
}
// Skip NPCs outside viewport for performance (if viewport bounds available)
if (viewBounds && (
sprite.x < viewBounds.left ||
sprite.x > viewBounds.right ||
sprite.y < viewBounds.top ||
sprite.y > viewBounds.bottom)) {
// Clear highlight if NPC is outside viewport
if (sprite.isHighlighted) {
sprite.isHighlighted = false;
sprite.clearTint();
// Clean up interaction sprite if exists
if (sprite.interactionIndicator) {
sprite.interactionIndicator.destroy();
delete sprite.interactionIndicator;
}
}
return;
}
// Check if NPC is hostile - don't show talk icon if so
const isNPCHostile = sprite.npcId && window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(sprite.npcId);
// Use squared distance for performance
const distanceSq = getInteractionDistance(player, sprite.x, sprite.y);
if (distanceSq <= INTERACTION_RANGE_SQ) {
if (!sprite.isHighlighted) {
sprite.isHighlighted = true;
// Add talk icon indicator for NPC (created on first highlight)
if (!sprite.interactionIndicator) {
addInteractionIndicator(sprite);
}
// Show talk icon only if NPC is NOT hostile
if (sprite.interactionIndicator && !isNPCHostile) {
sprite.interactionIndicator.setVisible(true);
sprite.talkIconVisible = true;
} else if (sprite.interactionIndicator && isNPCHostile) {
sprite.interactionIndicator.setVisible(false);
sprite.talkIconVisible = false;
}
} else if (sprite.interactionIndicator && !sprite.talkIconVisible && !isNPCHostile) {
// Update position of talk icon to stay pixel-perfect on NPC
const iconX = Math.round(sprite.x + 5);
const iconY = Math.round(sprite.y - 38);
sprite.interactionIndicator.setPosition(iconX, iconY);
sprite.interactionIndicator.setVisible(true);
sprite.talkIconVisible = true;
} else if (isNPCHostile && sprite.interactionIndicator && sprite.talkIconVisible) {
// Hide icon if NPC became hostile
sprite.interactionIndicator.setVisible(false);
sprite.talkIconVisible = false;
}
} else if (sprite.isHighlighted) {
sprite.isHighlighted = false;
sprite.clearTint();
// Hide talk icon when out of range
if (sprite.interactionIndicator) {
sprite.interactionIndicator.setVisible(false);
sprite.talkIconVisible = false;
}
} else if (sprite.interactionIndicator && sprite.talkIconVisible) {
// Update position every frame when icon is visible (smooth following)
const iconX = Math.round(sprite.x + 5);
const iconY = Math.round(sprite.y - 38);
sprite.interactionIndicator.setPosition(iconX, iconY);
}
});
}
});
}
function getInteractionSpriteKey(obj) {
// Determine which sprite to show based on the object's interaction type
// Check for NPCs first
if (obj._isNPC) {
return 'interact'; // Use generic interact sprite for NPCs
}
// Check for doors (they may not have scenarioData)
if (obj.doorProperties) {
if (obj.doorProperties.locked) {
// Check door lock type
const lockType = obj.doorProperties.lockType;
if (lockType === 'password') return 'password';
if (lockType === 'pin') return 'pin';
return 'keyway'; // Default to keyway for key locks or unknown types
}
return null; // Unlocked doors don't need overlay
}
if (!obj || !obj.scenarioData) {
return null;
}
const data = obj.scenarioData;
// Check for locked containers and items
if (data.locked === true) {
// Check specific lock type
const lockType = data.lockType;
if (lockType === 'password') return 'password';
if (lockType === 'pin') return 'pin';
if (lockType === 'biometric') return 'fingerprint';
// Default to keyway for key locks or unknown types
return 'keyway';
}
// Unlocked containers don't need an overlay
// (they'll be opened via the container minigame when interacted with)
if (data.contents) {
return null; // No overlay for unlocked containers
}
// Check for fingerprint collection
if (data.hasFingerprint === true) {
return 'fingerprint';
}
return null;
}
function addInteractionIndicator(obj) {
// Only add indicator if we have a game instance and the object has a scene
if (!gameRef || !obj.scene || !obj.scene.add) {
return;
}
// NPCs get the talk icon above their heads with pixel-perfect positioning
if (obj._isNPC) {
try {
// Talk icon positioned above NPC with pixel-perfect coordinates
const talkIconX = Math.round(obj.x + 5); // Centered above
const talkIconY = Math.round(obj.y - 38); // 32 pixels above
const indicator = obj.scene.add.image(talkIconX, talkIconY, 'talk');
indicator.setDepth(obj.depth + 1);
indicator.setVisible(false); // Hidden until player is in range
// Store reference for cleanup and visibility management
obj.interactionIndicator = indicator;
obj.talkIconVisible = false;
} catch (error) {
console.warn('Failed to add talk icon for NPC:', error);
}
return;
}
// Non-NPC objects use the standard interaction indicator sprite
const spriteKey = getInteractionSpriteKey(obj);
if (!spriteKey) return;
// Create indicator sprite centered over the object
try {
// Get the center of the parent sprite, accounting for its origin
const center = obj.getCenter();
// Position indicator above the object (accounting for parent's display height)
const indicatorX = center.x;
const indicatorY = center.y; // Position above with 10px offset
const indicator = obj.scene.add.image(indicatorX, indicatorY, spriteKey);
indicator.setDepth(999); // High depth to appear on top
indicator.setOrigin(0.5, 0.5); // Center the sprite
// indicator.setScale(0.5); // Scale down to be less intrusive
// Add pulsing animation
obj.scene.tweens.add({
targets: indicator,
alpha: { from: 1, to: 0.5 },
duration: 800,
yoyo: true,
repeat: -1
});
// Store reference for cleanup
obj.interactionIndicator = indicator;
} catch (error) {
console.warn('Failed to add interaction indicator:', error);
}
}
export function handleObjectInteraction(sprite) {
console.log('OBJECT INTERACTION', {
name: sprite.name,
id: sprite.objectId,
scenarioData: sprite.scenarioData
});
if (!sprite) {
console.warn('Invalid sprite');
return;
}
// Emit object interaction event (for NPCs to react)
if (window.eventDispatcher && sprite.scenarioData) {
window.eventDispatcher.emit('object_interacted', {
objectType: sprite.scenarioData.type,
objectName: sprite.scenarioData.name,
roomId: window.currentPlayerRoom
});
}
// Handle swivel chair interaction - trigger punch to kick it!
if (sprite.isSwivelChair && sprite.body) {
const player = window.player;
if (player && window.playerCombat) {
// Trigger punch instead of directly kicking the chair
// The punch system will detect the chair and apply kick velocity
window.playerCombat.punch();
}
return;
}
// Handle NPC sprite interaction
if (sprite._isNPC && sprite.npcId) {
console.log('NPC INTERACTION', { npcId: sprite.npcId });
if (window.MinigameFramework && window.npcManager) {
const npc = window.npcManager.getNPC(sprite.npcId);
if (npc) {
// Start person-chat minigame with this NPC
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: sprite.npcId,
title: npc.displayName || sprite.npcId
});
return;
} else {
console.warn('NPC not found in manager:', sprite.npcId);
}
} else {
console.warn('MinigameFramework or npcManager not available');
}
return;
}
if (!sprite.scenarioData) {
console.warn('Invalid sprite or missing scenario data');
return;
}
// Handle the Crypto Workstation - pick it up if takeable, or use it if in inventory
if (sprite.scenarioData.type === "workstation") {
// If it's in inventory (marked as non-takeable), open it
if (!sprite.scenarioData.takeable) {
console.log('OPENING WORKSTATION FROM INVENTORY');
if (window.openCryptoWorkstation) {
window.openCryptoWorkstation();
} else {
window.gameAlert('Crypto workstation not available', 'error', 'Error', 3000);
}
return;
}
// Otherwise, try to pick it up and add to inventory
console.log('WORKSTATION ADDED TO INVENTORY');
playUISound('item');
addToInventory(sprite);
window.gameAlert(`${sprite.scenarioData.name} added to inventory. You can now use it for cryptographic analysis.`, 'success', 'Item Acquired', 5000);
return;
}
// Handle the Notepad - open notes minigame
if (sprite.scenarioData.type === "notepad") {
if (window.startNotesMinigame) {
// Check if notes minigame is specifically already running
if (window.MinigameFramework && window.MinigameFramework.currentMinigame &&
window.MinigameFramework.currentMinigame.navigateToNoteIndex) {
console.log('Notes minigame already running, navigating to notepad note instead');
// If notes minigame is already running, just navigate to the notepad note
if (window.MinigameFramework.currentMinigame.navigateToNoteIndex) {
window.MinigameFramework.currentMinigame.navigateToNoteIndex(0);
}
return;
}
// Navigate to the notepad note (index 0) when clicking the notepad
// Create a minimal item just for navigation - no auto-add needed
const notepadItem = {
scenarioData: {
type: 'notepad',
name: 'Notepad'
}
};
window.startNotesMinigame(notepadItem, '', '', 0, false, false);
return;
}
}
// Handle the Bluetooth Scanner - only open minigame if it's already in inventory
if (sprite.scenarioData.type === "bluetooth_scanner") {
// Check if this is an inventory item (clicked from inventory)
const isInventoryItem = sprite.objectId && sprite.objectId.startsWith('inventory_');
if (isInventoryItem && window.startBluetoothScannerMinigame) {
console.log('Starting bluetooth scanner minigame from inventory');
window.startBluetoothScannerMinigame(sprite);
return;
}
// If it's not in inventory, let it fall through to the takeable logic below
}
// Handle the Fingerprint Kit - only open minigame if it's already in inventory
if (sprite.scenarioData.type === "fingerprint_kit") {
// Check if this is an inventory item (clicked from inventory)
const isInventoryItem = sprite.objectId && sprite.objectId.startsWith('inventory_');
if (isInventoryItem && window.startBiometricsMinigame) {
console.log('Starting biometrics minigame from inventory');
window.startBiometricsMinigame(sprite);
return;
}
// If it's not in inventory, let it fall through to the takeable logic below
}
// Handle the Lockpick Set - pick it up if takeable, or use it if in inventory
if (sprite.scenarioData.type === "lockpick" || sprite.scenarioData.type === "lockpickset") {
// If it's in inventory (marked as non-takeable), just acknowledge it
if (!sprite.scenarioData.takeable) {
console.log('LOCKPICK ALREADY IN INVENTORY');
window.gameAlert(`Used to pick pin tumbler locks.`, 'info', `${sprite.scenarioData.name}.`, 3000);
return;
}
// Otherwise, try to pick it up and add to inventory
console.log('LOCKPICK SET ADDED TO INVENTORY');
playUISound('item');
addToInventory(sprite);
window.gameAlert(`${sprite.scenarioData.name} added to inventory. You can now use it to pick locks.`, 'success', 'Item Acquired', 5000);
return;
}
// Handle biometric scanner interaction
if (sprite.scenarioData.biometricType === 'fingerprint') {
handleBiometricScan(sprite);
return;
}
// Check for fingerprint collection possibility
if (sprite.scenarioData.hasFingerprint) {
// Check if player has fingerprint kit
const hasKit = window.inventory.items.some(item =>
item && item.scenarioData &&
item.scenarioData.type === 'fingerprint_kit'
);
if (hasKit) {
const sample = collectFingerprint(sprite);
if (sample) {
return; // Exit after collecting fingerprint
}
} else {
window.gameAlert("You need a fingerprint kit to collect samples from this surface!", 'warning', 'Missing Equipment', 4000);
return;
}
}
// Skip range check for inventory items
const isInventoryItem = window.inventory && window.inventory.items.includes(sprite);
if (!isInventoryItem) {
// Check if player is in range
const player = window.player;
if (!player) return;
// Measure distance with direction-based offset
const distanceSq = getInteractionDistance(player, sprite.x, sprite.y);
if (distanceSq > INTERACTION_RANGE_SQ) {
console.log('INTERACTION_OUT_OF_RANGE', {
objectName: sprite.name,
objectId: sprite.objectId,
distance: Math.sqrt(distanceSq),
maxRange: Math.sqrt(INTERACTION_RANGE_SQ)
});
return;
}
}
const data = sprite.scenarioData;
// Handle container items (suitcase, briefcase, bags, bins, etc.) - check BEFORE lock check
if (data.type === 'suitcase' || data.type === 'briefcase' || data.type === 'bag1' || data.type === 'bin1' || data.contents) {
console.log('CONTAINER ITEM INTERACTION', data);
// Check if container is locked
if (data.locked === true) {
console.log('CONTAINER LOCKED - UNLOCK SYSTEM WILL HANDLE', data);
handleUnlock(sprite, 'item');
return;
}
// Container is unlocked (or has no lock) - launch the container minigame
console.log('CONTAINER UNLOCKED/OPEN - LAUNCHING MINIGAME', data);
handleContainerInteraction(sprite);
return;
}
// Check if item is locked (non-container items)
if (data.locked === true) {
console.log('ITEM LOCKED', data);
handleUnlock(sprite, 'item');
return;
}
let message = `${data.name} `;
if (data.observations) {
message += `Observations: ${data.observations}\n`;
}
// For phone type objects, use phone-chat with runtime conversion or direct NPC access
if (data.type === 'phone' && (data.text || data.voice || data.npcIds)) {
console.log('Phone object detected:', { type: data.type, text: data.text, voice: data.voice, npcIds: data.npcIds });
// Check if phone-chat system is available
if (window.MinigameFramework && window.npcManager) {
const phoneId = data.phoneId || 'default_phone';
// Check if phone has already been converted or has npcIds
if (data.npcIds && data.npcIds.length > 0) {
console.log('Phone has npcIds, opening phone-chat directly', { npcIds: data.npcIds });
// Phone already has NPCs, open directly
window.MinigameFramework.startMinigame('phone-chat', null, {
phoneId: phoneId,
npcIds: data.npcIds,
title: data.name || 'Phone'
});
return;
}
// Need to convert simple message - import the converter
import('../utils/phone-message-converter.js').then(module => {
const PhoneMessageConverter = module.default;
// Convert simple message to virtual NPC
const npcId = PhoneMessageConverter.convertAndRegister(data, window.npcManager);
if (npcId) {
// Update phone object to reference the NPC
data.phoneId = phoneId;
data.npcIds = [npcId];
// Open phone-chat with converted NPC
window.MinigameFramework.startMinigame('phone-chat', null, {
phoneId: phoneId,
title: data.name || 'Phone'
});
} else {
console.error('Failed to convert phone object to virtual NPC');
}
}).catch(error => {
console.error('Failed to load PhoneMessageConverter:', error);
});
return; // Exit early
} else {
console.warn('Phone-chat system not available (MinigameFramework or npcManager missing)');
}
}
// For text_file type objects, use the text file minigame
if (data.type === 'text_file' && data.text) {
console.log('Text file object detected:', { type: data.type, name: data.name, text: data.text });
// Start the text file minigame
if (window.MinigameFramework) {
// Initialize the framework if not already done
if (!window.MinigameFramework.mainGameScene && window.game) {
window.MinigameFramework.init(window.game);
}
const minigameParams = {
title: `Text File - ${data.name || 'Unknown File'}`,
fileName: data.name || 'Unknown File',
fileContent: data.text,
fileType: data.fileType || 'text',
observations: data.observations,
lockable: sprite,
source: data.source || 'Unknown Source',
onComplete: (success, result) => {
console.log('Text file minigame completed:', success, result);
}
};
window.MinigameFramework.startMinigame('text-file', null, minigameParams);
return; // Exit early since minigame handles the interaction
}
}
if (data.readable && data.text) {
message += `Text: ${data.text}\n`;
// For notes type objects, use the notes minigame
if (data.type === 'notes' && data.text) {
// Start the notes minigame
if (window.startNotesMinigame) {
window.startNotesMinigame(sprite, data.text, data.observations);
return; // Exit early since minigame handles the interaction
}
}
// Add readable text as a note (fallback for other readable objects)
// Skip notepad items since they're handled specially
if (data.text.trim().length > 0 && data.type !== 'notepad') {
const addedNote = window.addNote(data.name, data.text, data.important || false);
if (addedNote) {
window.gameAlert(`Added "${data.name}" to your notes.`, 'info', 'Note Added', 3000);
// If this is a note in the inventory, remove it after adding to notes list
if (isInventoryItem && data.type === 'notes') {
setTimeout(() => {
if (removeFromInventory(sprite)) {
window.gameAlert(`Removed "${data.name}" from inventory after recording in notes.`, 'success', 'Inventory Updated', 3000);
}
}, 1000);
}
}
}
}
if (data.takeable) {
// Check if it's already in inventory
const itemIdentifier = createItemIdentifier(sprite.scenarioData);
const isInInventory = window.inventory.items.some(item =>
item && createItemIdentifier(item.scenarioData) === itemIdentifier
);
if (!isInInventory) {
console.log('INVENTORY ITEM ADDED', { item: itemIdentifier });
playUISound('item');
addToInventory(sprite);
}
}
// Show notification
window.gameAlert(message, 'info', data.name, 5000);
}
// Handle container item interactions
function handleContainerInteraction(sprite) {
const data = sprite.scenarioData;
console.log('Handling container interaction:', data);
// Check if container has contents
if (!data.contents || data.contents.length === 0) {
window.gameAlert(`${data.name} is empty.`, 'info', 'Empty Container', 3000);
return;
}
// Start the container minigame
if (window.startContainerMinigame) {
window.startContainerMinigame(sprite, data.contents, data.takeable);
} else {
console.error('Container minigame not available');
window.gameAlert('Container minigame not available', 'error', 'Error', 3000);
}
}
// Try to interact with the nearest interactable object within range
export function tryInteractWithNearest() {
const player = window.player;
if (!player) {
return;
}
const px = player.x;
const py = player.y;
let nearestObject = null;
let nearestDistance = INTERACTION_RANGE; // Only consider objects within interaction range
// Get player's facing direction and convert to angle for direction filtering
const playerDirection = player.direction || 'down';
let facingAngle = 0;
let angleTolerance = 70; // degrees - how wide the cone in front of player is
// Determine facing angle based on direction
// In canvas/Phaser: right=0°, down=90°, left=180°, up=270°
switch(playerDirection) {
case 'right':
facingAngle = 0;
break;
case 'down-right':
facingAngle = 45;
break;
case 'down':
facingAngle = 90;
break;
case 'down-left':
facingAngle = 135;
break;
case 'left':
facingAngle = 180;
break;
case 'up-left':
facingAngle = 225;
break;
case 'up':
facingAngle = 270; // Use 270 instead of -90
break;
case 'up-right':
facingAngle = 315; // Use 315 instead of -45
break;
default: // Fallback for any unknown directions
facingAngle = 90; // Default to down
}
// Helper function to check if an object is in front of the player
function isInFrontOfPlayer(objX, objY) {
const dx = objX - px;
const dy = objY - py;
// Calculate angle to object (in canvas coordinates where Y increases downward)
let angleToObject = Math.atan2(dy, dx) * 180 / Math.PI;
// Normalize to 0-360
angleToObject = (angleToObject + 360) % 360;
// Calculate angular difference
let angleDiff = Math.abs(facingAngle - angleToObject);
if (angleDiff > 180) {
angleDiff = 360 - angleDiff;
}
return angleDiff <= angleTolerance;
}
// Check all objects in all rooms
Object.entries(rooms).forEach(([roomId, room]) => {
if (!room.objects) return;
Object.values(room.objects).forEach(obj => {
// Only consider interactable, active, and visible objects
if (!obj.active || !obj.interactable || !obj.visible) {
return;
}
// Calculate distance with direction-based offset
const distanceSq = getInteractionDistance(player, obj.x, obj.y);
const distance = Math.sqrt(distanceSq);
// Check if within range and in front of player
if (distance <= INTERACTION_RANGE && isInFrontOfPlayer(obj.x, obj.y)) {
if (distance < nearestDistance) {
nearestDistance = distance;
nearestObject = obj;
}
}
});
// Also check door sprites (including all doors, not just locked ones)
if (room.doorSprites) {
Object.values(room.doorSprites).forEach(door => {
// Only consider active doors (check all doors, not just locked)
if (!door.active || !door.doorProperties) {
return;
}
// Calculate distance with direction-based offset
const distanceSq = getInteractionDistance(player, door.x, door.y);
const distance = Math.sqrt(distanceSq);
// Check if within range and in front of player
if (distance <= INTERACTION_RANGE && isInFrontOfPlayer(door.x, door.y)) {
if (distance < nearestDistance) {
nearestDistance = distance;
nearestObject = door;
}
}
});
}
// Also check NPC sprites
if (room.npcSprites) {
room.npcSprites.forEach(sprite => {
// Only consider active NPCs
if (!sprite.active || !sprite._isNPC) {
return;
}
// Calculate distance with direction-based offset
const distanceSq = getInteractionDistance(player, sprite.x, sprite.y);
const distance = Math.sqrt(distanceSq);
// Check if within range and in front of player
if (distance <= INTERACTION_RANGE && isInFrontOfPlayer(sprite.x, sprite.y)) {
if (distance < nearestDistance) {
nearestDistance = distance;
nearestObject = sprite;
}
}
});
}
});
// Interact with the nearest object if one was found
if (nearestObject) {
// Check if this is a door (doors have doorProperties instead of scenarioData)
if (nearestObject.doorProperties) {
// Handle door interaction - triggers unlock/open sequence based on lock state
handleDoorInteraction(nearestObject);
} else if (nearestObject._isNPC) {
// Handle NPC interaction with hostile check
tryInteractWithNPC(nearestObject);
} else {
// Handle regular object interaction
handleObjectInteraction(nearestObject);
}
}
}
// Handle NPC interaction by sprite reference
export function tryInteractWithNPC(npcSprite) {
if (!npcSprite || !npcSprite._isNPC) {
return false;
}
const player = window.player;
if (!player) {
return false;
}
// Check if NPC is within interaction range of the player
const distanceSq = getInteractionDistance(player, npcSprite.x, npcSprite.y);
const distance = Math.sqrt(distanceSq);
// Only interact if within range
if (distance <= INTERACTION_RANGE) {
// Check if NPC is hostile - if so, trigger punch instead of conversation
const npcId = npcSprite.npcId;
if (npcId && window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(npcId)) {
// Hostile NPC - punch instead of talk
if (window.playerCombat) {
window.playerCombat.punch();
}
return true;
}
// Normal NPC interaction (conversation)
handleObjectInteraction(npcSprite);
return true; // Interaction successful
}
// Out of range - caller should handle movement
return false;
}
// Export for global access
window.checkObjectInteractions = checkObjectInteractions;
window.handleObjectInteraction = handleObjectInteraction;
window.handleContainerInteraction = handleContainerInteraction;
window.tryInteractWithNearest = tryInteractWithNearest;
window.tryInteractWithNPC = tryInteractWithNPC;