mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
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
1021 lines
40 KiB
JavaScript
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;
|