Files
BreakEscape/js/systems/interactions.js
Z. Cliffe Schreuders ef5d85b744 Refactor InkEngine to allow manual control of story continuation; enhance continue method for better text accumulation and logging
Update interactions.js to reference new version of constants

Improve NPCBarkSystem to dynamically import phone-chat minigame and handle fallback UI more gracefully

Modify constants.js to conditionally export GAME_CONFIG based on Phaser availability

Update implementation log for Phone Chat Minigame, detailing completed modules and next steps

Create detailed implementation plan for Phone Chat Minigame, outlining structure, features, and integration points

Add test HTML page for Phone Chat Minigame, including setup, chat tests, and history management functionalities
2025-10-29 19:17:51 +00:00

808 lines
31 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';
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
}
export function checkObjectInteractions() {
// 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;
obj.setTint(0x4da6ff); // Blue tint for interactable objects
// Add interaction indicator sprite
addInteractionIndicator(obj);
}
} else if (obj.isHighlighted) {
obj.isHighlighted = false;
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;
}
}
});
}
});
}
function getInteractionSpriteKey(obj) {
// Determine which sprite to show based on the object's interaction type
// Check for doors first (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;
}
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;
}
// Handle swivel chair interaction - send it flying!
if (sprite.isSwivelChair && sprite.body) {
const player = window.player;
if (player) {
// Calculate direction from player to chair
const dx = sprite.x - player.x;
const dy = sprite.y - player.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
// Normalize the direction vector
const dirX = dx / distance;
const dirY = dy / distance;
// Apply a strong kick velocity
const kickForce = 600; // Pixels per second
sprite.body.setVelocity(dirX * kickForce, dirY * kickForce);
// Trigger spin direction calculation for visual rotation
if (window.calculateChairSpinDirection) {
window.calculateChairSpinDirection(player, sprite);
}
// Show feedback message
console.log('SWIVEL CHAIR KICKED', {
chairName: sprite.name,
velocity: { x: dirX * kickForce, y: dirY * kickForce }
});
}
}
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');
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');
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 the phone messages minigame
if (data.type === 'phone' && (data.text || data.voice)) {
console.log('Phone object detected:', { type: data.type, text: data.text, voice: data.voice });
// Start the phone messages minigame
if (window.MinigameFramework) {
// Initialize the framework if not already done
if (!window.MinigameFramework.mainGameScene && window.game) {
window.MinigameFramework.init(window.game);
}
const messages = [];
// Add text message if available
if (data.text) {
messages.push({
type: 'text',
sender: data.sender || 'Unknown',
text: data.text,
timestamp: data.timestamp || 'Unknown time',
read: false
});
}
// Add voice message if available
if (data.voice) {
messages.push({
type: 'voice',
sender: data.sender || 'Unknown',
text: data.text || null, // text is optional for voice messages
voice: data.voice,
timestamp: data.timestamp || 'Unknown time',
read: false
});
}
const minigameParams = {
title: data.name || 'Phone Messages',
messages: messages,
observations: data.observations,
lockable: sprite,
onComplete: (success, result) => {
console.log('Phone messages minigame completed:', success, result);
}
};
window.MinigameFramework.startMinigame('phone-messages', null, minigameParams);
return; // Exit early since minigame handles the interaction
}
}
// 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 });
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;
}
}
});
}
});
// 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 {
// Handle regular object interaction
handleObjectInteraction(nearestObject);
}
}
}
// Export for global access
window.checkObjectInteractions = checkObjectInteractions;
window.handleObjectInteraction = handleObjectInteraction;
window.handleContainerInteraction = handleContainerInteraction;
window.tryInteractWithNearest = tryInteractWithNearest;