feat: Implement NPC security guard interaction for lockpicking detection

- Added a new security guard NPC with conversation flow for lockpicking attempts.
- Integrated influence system to determine NPC reactions based on player choices.
- Created a new JSON scenario for the security guard's behavior and interactions.
- Refactored lockpicking system to allow NPC interruptions during attempts.
- Developed a test scenario to visualize NPC patrol and line-of-sight detection.
- Added a debug panel for testing line-of-sight visualization in the game.
This commit is contained in:
Z. Cliffe Schreuders
2025-11-11 01:07:05 +00:00
parent f41b2a41ac
commit 72e2e6293f
11 changed files with 1210 additions and 1 deletions

View File

@@ -740,6 +740,11 @@ export function update() {
window.npcBehaviorManager.update(this.time.now, this.time.delta);
}
// Update NPC LOS visualizations if enabled
if (window.npcManager && window.npcManager.losVisualizationEnabled) {
window.npcManager.updateLOSVisualizations(this);
}
// Check for object interactions
checkObjectInteractions.call(this);

View File

@@ -1963,6 +1963,37 @@ function createNPCSpritesForRoom(roomId, roomData) {
}
}
});
// Auto-enable LOS visualization if any NPC has los.visualize = true
if (window.npcManager && gameRef) {
console.log(`👁️ Checking ${npcsInRoom.length} NPCs for LOS visualization requests...`);
npcsInRoom.forEach(npc => {
console.log(` NPC "${npc.id}": los=${!!npc.los}, visualize=${npc.los?.visualize}`);
});
const hasVisualNPC = npcsInRoom.some(npc => npc.los?.visualize === true);
console.log(`👁️ hasVisualNPC: ${hasVisualNPC}`);
if (hasVisualNPC) {
console.log(`👁️ Auto-enabling LOS visualization for room ${roomId}`);
console.log(` npcManager: ${!!window.npcManager}`);
console.log(` gameRef: ${!!gameRef}`);
// Get the current scene - use gameRef.scene (the current scene manager) or fall back to window.game
const currentScene = gameRef.scene || window.game?.scene?.scenes?.[0];
console.log(` currentScene: ${!!currentScene}, key: ${currentScene?.key}`);
if (currentScene) {
window.npcManager.setLOSVisualization(true, currentScene);
} else {
console.warn(`⚠️ Cannot get current scene for LOS visualization`);
}
} else {
console.log(`👁️ No NPCs requesting LOS visualization in room ${roomId}`);
}
} else {
console.log(`👁️ Cannot auto-enable LOS: npcManager=${!!window.npcManager}, gameRef=${!!gameRef}`);
}
}
/**

View File

@@ -184,6 +184,110 @@ function initializeGame() {
window.addEventListener('orientationchange', handleOrientationChange);
document.addEventListener('fullscreenchange', handleOrientationChange);
// Check for LOS visualization debug flag
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('debug-los') || urlParams.has('los')) {
// Delay to ensure scene is ready
setTimeout(() => {
const mainScene = window.game?.scene?.scenes?.[0];
if (mainScene && window.npcManager) {
console.log('🔍 Enabling LOS visualization (from URL parameter)');
window.npcManager.setLOSVisualization(true, mainScene);
}
}, 1000);
}
// Add console helper
window.enableLOS = function() {
console.log('🔍 enableLOS() called');
console.log(' game:', !!window.game);
console.log(' game.scene:', !!window.game?.scene);
console.log(' scenes:', window.game?.scene?.scenes?.length ?? 0);
const mainScene = window.game?.scene?.scenes?.[0];
console.log(' mainScene:', !!mainScene, mainScene?.key);
console.log(' npcManager:', !!window.npcManager);
if (!mainScene) {
console.error('❌ Could not get main scene');
// Try to find any active scene
if (window.game?.scene?.scenes) {
for (let i = 0; i < window.game.scene.scenes.length; i++) {
console.log(` Available scene[${i}]:`, window.game.scene.scenes[i].key, 'isActive:', window.game.scene.scenes[i].isActive());
}
}
return;
}
if (!window.npcManager) {
console.error('❌ npcManager not available');
return;
}
console.log('🎯 Setting LOS visualization with scene:', mainScene.key);
window.npcManager.setLOSVisualization(true, mainScene);
console.log('✅ LOS visualization enabled');
};
window.disableLOS = function() {
if (window.npcManager) {
window.npcManager.setLOSVisualization(false);
console.log('✅ LOS visualization disabled');
} else {
console.error('❌ npcManager not available');
}
};
// Test graphics rendering
window.testGraphics = function() {
console.log('🧪 Testing graphics rendering...');
const scene = window.game?.scene?.scenes?.[0];
if (!scene) {
console.error('❌ No scene found');
return;
}
console.log('📊 Scene:', scene.key, 'Active:', scene.isActive());
const test = scene.add.graphics();
console.log('✅ Created graphics object:', {
exists: !!test,
hasScene: !!test.scene,
depth: test.depth,
alpha: test.alpha,
visible: test.visible
});
test.fillStyle(0xff0000, 0.5);
test.fillRect(100, 100, 50, 50);
console.log('✅ Drew red square at (100, 100)');
console.log(' If you see a RED SQUARE on screen, graphics rendering is working!');
console.log(' If NOT, check browser console for errors');
// Clean up after 5 seconds
setTimeout(() => {
test.destroy();
console.log('🧹 Test graphics cleaned up');
}, 5000);
};
// Get detailed LOS status
window.losStatus = function() {
console.log('📡 LOS System Status:');
console.log(' Enabled:', window.npcManager?.losVisualizationEnabled ?? 'N/A');
console.log(' NPCs loaded:', window.npcManager?.npcs?.size ?? 0);
console.log(' Graphics objects:', window.npcManager?.losVisualizations?.size ?? 0);
if (window.npcManager?.npcs?.size > 0) {
for (const npc of window.npcManager.npcs.values()) {
console.log(` NPC: "${npc.id}"`);
console.log(` LOS enabled: ${npc.los?.enabled ?? false}`);
console.log(` Position: (${npc.sprite?.x.toFixed(0) ?? 'N/A'}, ${npc.sprite?.y.toFixed(0) ?? 'N/A'})`);
console.log(` Facing: ${npc.facingDirection ?? npc.direction ?? 'N/A'}°`);
}
}
};
// Initial setup
setTimeout(setupPixelArt, 100);
}

345
js/systems/npc-los.js Normal file
View File

@@ -0,0 +1,345 @@
/**
* NPC LINE-OF-SIGHT (LOS) SYSTEM
* ===============================
*
* Handles visibility detection for NPCs with configurable:
* - Detection range (in pixels)
* - Field-of-view angle (in degrees, cone shape)
* - Facing direction (auto-calculated from NPC's current direction)
* - Visualization (debug cone rendering)
*
* Used to determine if an NPC can "see" the player or events like lockpicking.
*/
/**
* Check if target is within NPC's line of sight
* @param {Object} npc - NPC object with position and direction
* @param {Object} target - Target position { x, y } or entity with sprite
* @param {Object} losConfig - LOS configuration { range, angle, enabled }
* @returns {boolean} True if target is in LOS
*/
export function isInLineOfSight(npc, target, losConfig = {}) {
// Default LOS config if not provided
const {
range = 200, // Detection range in pixels
angle = 120, // Field of view angle in degrees (120 = 60° on each side of facing direction)
enabled = true
} = losConfig;
if (!enabled) return true; // If LOS disabled, always return true (can see everything)
// Get NPC position
const npcPos = getNPCPosition(npc);
if (!npcPos) return false;
// Get target position
const targetPos = getTargetPosition(target);
if (!targetPos) return false;
// Calculate distance
const distance = Phaser.Math.Distance.Between(npcPos.x, npcPos.y, targetPos.x, targetPos.y);
if (distance > range) {
return false; // Target outside range
}
// Get NPC's facing direction (0-360 degrees)
const npcFacing = getNPCFacingDirection(npc);
// Calculate angle to target from NPC
const angleToTarget = Phaser.Math.Angle.Between(npcPos.x, npcPos.y, targetPos.x, targetPos.y);
const angleToTargetDegrees = Phaser.Math.RadToDeg(angleToTarget);
// Normalize angles to 0-360
const normalizedFacing = normalizeAngle(npcFacing);
const normalizedTarget = normalizeAngle(angleToTargetDegrees);
// Calculate angular difference (shortest arc)
const angleDiff = shortestAngularDistance(normalizedFacing, normalizedTarget);
const maxAngle = angle / 2; // Half angle on each side
return Math.abs(angleDiff) <= maxAngle;
}
/**
* Get NPC's current position
*/
function getNPCPosition(npc) {
if (!npc) return null;
// If NPC has _sprite property (how it's stored: npc._sprite = sprite)
if (npc._sprite && typeof npc._sprite.getCenter === 'function') {
return npc._sprite.getCenter();
}
// If NPC has sprite property (Phaser sprite)
if (npc.sprite && typeof npc.sprite.getCenter === 'function') {
return npc.sprite.getCenter();
}
// If NPC has x, y properties (raw coordinates)
if (npc.x !== undefined && npc.y !== undefined) {
return { x: npc.x, y: npc.y };
}
// If NPC has position property
if (npc.position && npc.position.x !== undefined && npc.position.y !== undefined) {
return { x: npc.position.x, y: npc.position.y };
}
return null;
}
/**
* Get target position (handles both plain objects and sprites)
*/
function getTargetPosition(target) {
if (!target) return null;
// If target has getCenter method (Phaser sprite)
if (typeof target.getCenter === 'function') {
return target.getCenter();
}
// If target has x, y properties
if (target.x !== undefined && target.y !== undefined) {
return { x: target.x, y: target.y };
}
return null;
}
/**
* Get NPC's current facing direction in degrees (0-360)
* Tries multiple sources: stored direction, sprite direction, patrol direction
*/
export function getNPCFacingDirection(npc) {
if (!npc) return 0;
// If NPC has explicit facing direction property
if (npc.facingDirection !== undefined) {
return npc.facingDirection;
}
// Try to get direction from behavior system (most current)
if (window.npcBehaviorManager) {
const behavior = window.npcBehaviorManager.getBehavior?.(npc.id);
if (behavior && behavior.direction !== undefined) {
const directions = {
'down': 90, // Down (south)
'up': 270, // Up (north)
'left': 180, // Left (west)
'right': 0, // Right (east)
'down-left': 225,
'down-right': 45,
'up-left': 225,
'up-right': 315
};
return directions[behavior.direction] ?? 90;
}
}
// If NPC has _sprite (stored sprite reference) with rotation
if (npc._sprite && npc._sprite.rotation !== undefined) {
return Phaser.Math.RadToDeg(npc._sprite.rotation);
}
// If NPC has sprite with rotation
if (npc.sprite && npc.sprite.rotation !== undefined) {
return Phaser.Math.RadToDeg(npc.sprite.rotation);
}
// If NPC has direction property (string or numeric)
if (npc.direction !== undefined) {
if (typeof npc.direction === 'string') {
const directions = {
'down': 90,
'up': 270,
'left': 180,
'right': 0,
'down-left': 225,
'down-right': 45,
'up-left': 225,
'up-right': 315
};
return directions[npc.direction] ?? 90;
} else if (typeof npc.direction === 'number') {
// Numeric: 0=down, 1=left, 2=up, 3=right
const directions = [90, 180, 270, 0];
return directions[npc.direction % 4] ?? 0;
}
}
// Default: facing down (90 degrees in Phaser convention for top-down)
return 90;
}
/**
* Normalize angle to 0-360 range
*/
function normalizeAngle(angle) {
let normalized = angle % 360;
if (normalized < 0) normalized += 360;
return normalized;
}
/**
* Calculate shortest angular distance between two angles
* Returns signed value: positive = clockwise, negative = counter-clockwise
*/
function shortestAngularDistance(from, to) {
let diff = to - from;
// Normalize to -180 to 180
while (diff > 180) diff -= 360;
while (diff < -180) diff += 360;
return diff;
}
/**
* Draw LOS cone for debugging
* @param {Phaser.Scene} scene - Phaser scene for drawing
* @param {Object} npc - NPC object
* @param {Object} losConfig - LOS configuration
* @param {number} color - Hex color for cone (default 0x00ff00)
* @param {number} alpha - Alpha value for cone (default 0.2)
*/
export function drawLOSCone(scene, npc, losConfig = {}, color = 0x00ff00, alpha = 0.2) {
const {
range = 200,
angle = 120,
enabled = true
} = losConfig;
if (!enabled || !scene || !scene.add) {
console.log('🔴 Cannot draw LOS cone - missing scene or disabled');
return null;
}
const npcPos = getNPCPosition(npc);
if (!npcPos) {
console.log('🔴 Cannot draw LOS cone - NPC position not found', {
npcId: npc?.id,
hasSprite: !!npc?.sprite,
hasX: npc?.x !== undefined,
hasPosition: !!npc?.position
});
return null;
}
// Scale the range to 50% size
const scaledRange = range * 0.5;
// Set cone opacity to 20%
const coneAlpha = 0.2;
console.log(`🟢 Drawing LOS cone for NPC at (${npcPos.x.toFixed(0)}, ${npcPos.y.toFixed(0)}), range: ${scaledRange}px (50% of ${range}), angle: ${angle}°`);
const npcFacing = getNPCFacingDirection(npc);
console.log(` NPC facing: ${npcFacing.toFixed(0)}°`);
const npcFacingRad = Phaser.Math.DegToRad(npcFacing);
const halfAngleRad = Phaser.Math.DegToRad(angle / 2);
// Create graphics object for the cone
const graphics = scene.add.graphics();
console.log(` 📊 Graphics object created - checking properties:`, {
graphicsExists: !!graphics,
hasScene: !!graphics.scene,
sceneKey: graphics.scene?.key,
canAdd: typeof graphics.add === 'function'
});
// Draw outer range circle (light, semi-transparent)
graphics.lineStyle(1, color, 0.2);
graphics.strokeCircle(npcPos.x, npcPos.y, scaledRange);
console.log(` ⭕ Range circle drawn at (${npcPos.x}, ${npcPos.y}) radius: ${scaledRange}`);
// Draw the cone fill
graphics.fillStyle(color, coneAlpha);
graphics.lineStyle(2, color, 0.2);
// Calculate cone points
const conePoints = [];
conePoints.push(new Phaser.Geom.Point(npcPos.x, npcPos.y)); // Center (NPC position)
// Left edge of cone
const leftAngle = npcFacingRad - halfAngleRad;
const leftPoint = new Phaser.Geom.Point(
npcPos.x + scaledRange * Math.cos(leftAngle),
npcPos.y + scaledRange * Math.sin(leftAngle)
);
conePoints.push(leftPoint);
// Arc from left to right (approximate with segments)
const segments = Math.max(12, Math.floor(angle / 5));
for (let i = 1; i < segments; i++) {
const t = i / segments;
const currentAngle = npcFacingRad - halfAngleRad + (angle * Math.PI / 180) * t;
conePoints.push(new Phaser.Geom.Point(
npcPos.x + scaledRange * Math.cos(currentAngle),
npcPos.y + scaledRange * Math.sin(currentAngle)
));
}
// Right edge of cone
const rightAngle = npcFacingRad + halfAngleRad;
conePoints.push(new Phaser.Geom.Point(
npcPos.x + scaledRange * Math.cos(rightAngle),
npcPos.y + scaledRange * Math.sin(rightAngle)
));
// Close back to center
conePoints.push(new Phaser.Geom.Point(npcPos.x, npcPos.y));
// Draw the cone polygon
graphics.fillPoints(conePoints, true);
graphics.strokePoints(conePoints, true);
// Draw NPC position indicator (bright circle)
graphics.fillStyle(color, 0.6);
graphics.fillCircle(npcPos.x, npcPos.y, 10);
// Draw a line showing the facing direction (to front of cone)
graphics.lineStyle(3, color, 0.1);
const dirLength = scaledRange * 0.4;
graphics.lineBetween(
npcPos.x,
npcPos.y,
npcPos.x + dirLength * Math.cos(npcFacingRad),
npcPos.y + dirLength * Math.sin(npcFacingRad)
);
// Draw angle wedge markers
graphics.lineStyle(1, color, 0.5);
graphics.lineBetween(npcPos.x, npcPos.y, leftPoint.x, leftPoint.y);
graphics.lineBetween(npcPos.x, npcPos.y,
npcPos.x + scaledRange * Math.cos(rightAngle),
npcPos.y + scaledRange * Math.sin(rightAngle)
);
// Set depth on top of other objects (was -999, now 9999)
graphics.setDepth(9999); // On top of everything
graphics.setAlpha(1.0); // Ensure not transparent
console.log(`✅ LOS cone rendered successfully:`, {
positionX: npcPos.x.toFixed(0),
positionY: npcPos.y.toFixed(0),
depth: graphics.depth,
alpha: graphics.alpha,
visible: graphics.visible,
active: graphics.active,
pointsCount: conePoints.length
});
return graphics;
}
/**
* Clean up LOS visualization
* @param {Phaser.GameObjects.Graphics} graphics - Graphics object to destroy
*/
export function clearLOSCone(graphics) {
if (graphics && typeof graphics.destroy === 'function') {
graphics.destroy();
}
}

View File

@@ -1,6 +1,8 @@
// NPCManager with event → knot auto-mapping and conversation history
// OPTIMIZED: InkEngine caching, event listener cleanup, debug logging
// default export NPCManager
import { isInLineOfSight, drawLOSCone, clearLOSCone, getNPCFacingDirection } from './npc-los.js';
export default class NPCManager {
constructor(eventDispatcher, barkSystem = null) {
this.eventDispatcher = eventDispatcher;
@@ -18,6 +20,10 @@ export default class NPCManager {
this.inkEngineCache = new Map(); // { npcId: inkEngine }
this.storyCache = new Map(); // { storyPath: storyJson }
// LOS Visualization
this.losVisualizations = new Map(); // { npcId: graphicsObject }
this.losVisualizationEnabled = false; // Toggle LOS cone rendering
// OPTIMIZATION: Debug mode (set via window.NPC_DEBUG = true)
this.debug = false;
}
@@ -112,6 +118,99 @@ export default class NPCManager {
return this.npcs.get(id) || null;
}
/**
* Check if any NPC in a room should trigger person-chat instead of lockpicking
* Considers NPC line-of-sight and facing direction
* Returns the NPC if one should handle lockpick_used_in_view with person-chat
* Otherwise returns null
*/
shouldInterruptLockpickingWithPersonChat(roomId, playerPosition = null) {
if (!roomId) return null;
console.log(`👁️ [LOS CHECK] shouldInterruptLockpickingWithPersonChat: roomId="${roomId}", playerPos=${playerPosition ? `(${playerPosition.x.toFixed(0)}, ${playerPosition.y.toFixed(0)})` : 'null'}`);
for (const npc of this.npcs.values()) {
// NPC must be in the specified room and be a 'person' type NPC
if (npc.roomId !== roomId || npc.npcType !== 'person') continue;
console.log(`👁️ [LOS CHECK] Checking NPC: "${npc.id}" (room: ${npc.roomId}, type: ${npc.npcType})`);
// Check if NPC has lockpick_used_in_view event mapping with person-chat
if (npc.eventMappings && Array.isArray(npc.eventMappings)) {
const lockpickMapping = npc.eventMappings.find(mapping =>
mapping.eventPattern === 'lockpick_used_in_view' &&
mapping.conversationMode === 'person-chat'
);
if (!lockpickMapping) {
console.log(`👁️ [LOS CHECK] ✗ NPC has no lockpick_used_in_view mapping`);
continue;
}
console.log(`👁️ [LOS CHECK] ✓ NPC has lockpick_used_in_view→person-chat mapping`);
// Check LOS configuration
const losConfig = npc.los || {
enabled: true,
range: 300, // Default detection range
angle: 120 // Default 120° field of view
};
// If player position provided, check if player is in LOS
if (playerPosition) {
// Get detailed information for debugging
// Try to get sprite from npc._sprite (how it's stored), then npc.sprite, then npc position
const sprite = npc._sprite || npc.sprite;
const npcPos = (sprite && typeof sprite.getCenter === 'function') ?
sprite.getCenter() :
{ x: npc.x ?? 0, y: npc.y ?? 0 };
console.log(`👁️ [LOS CHECK] npcPos: ${npcPos ? `(${npcPos.x}, ${npcPos.y})` : 'NULL'}, losConfig: range=${losConfig.range}, angle=${losConfig.angle}`);
// Ensure npcPos is valid before using
if (npcPos && npcPos.x !== undefined && npcPos.y !== undefined &&
!Number.isNaN(npcPos.x) && !Number.isNaN(npcPos.y)) {
const distance = Math.sqrt(
Math.pow(playerPosition.x - npcPos.x, 2) +
Math.pow(playerPosition.y - npcPos.y, 2)
);
// Calculate angle to player
const angleRad = Math.atan2(playerPosition.y - npcPos.y, playerPosition.x - npcPos.x);
const angleToPlayer = (angleRad * 180 / Math.PI + 360) % 360;
// Get NPC facing direction for debugging
const npcFacing = getNPCFacingDirection(npc);
const inLOS = isInLineOfSight(npc, playerPosition, losConfig);
console.log(`👁️ [LOS CHECK] NPC Facing: ${npcFacing.toFixed(1)}°, Distance: ${distance.toFixed(1)}px (range: ${losConfig.range}), Angle: ${angleToPlayer.toFixed(1)}° (FOV: ${losConfig.angle}°), LOS: ${inLOS}`);
if (!inLOS) {
console.log(
`👁️ NPC "${npc.id}" CANNOT see player\n` +
` Position: NPC(${npcPos.x.toFixed(0)}, ${npcPos.y.toFixed(0)}) → Player(${playerPosition.x.toFixed(0)}, ${playerPosition.y.toFixed(0)})\n` +
` Distance: ${distance.toFixed(1)}px (range: ${losConfig.range}px) ${distance > losConfig.range ? '❌ TOO FAR' : '✅ in range'}\n` +
` Angle to Player: ${angleToPlayer.toFixed(1)}° (FOV: ${losConfig.angle}°)`
);
continue;
}
} else {
console.log(`👁️ [LOS CHECK] Position invalid, checking LOS anyway...`);
if (!isInLineOfSight(npc, playerPosition, losConfig)) {
// Position unavailable but still check LOS detection
continue;
}
}
}
console.log(`<EFBFBD>🚫 INTERRUPTING LOCKPICKING: NPC "${npc.id}" in room "${roomId}" can see player and has person-chat mapped to lockpick event`);
return npc;
}
}
return null;
}
// Set bark system (can be set after construction)
setBarkSystem(barkSystem) {
this.barkSystem = barkSystem;
@@ -203,7 +302,8 @@ export default class NPCManager {
once: mapping.onceOnly || mapping.once,
cooldown: mapping.cooldown,
condition: mapping.condition,
maxTriggers: mapping.maxTriggers // Add max trigger limit
maxTriggers: mapping.maxTriggers, // Add max trigger limit
conversationMode: mapping.conversationMode // Add conversation mode (e.g., 'person-chat')
};
console.log(` 📌 Registering listener for event: ${eventPattern}${config.knot}`);
@@ -296,6 +396,40 @@ export default class NPCManager {
console.log(`📍 Updated ${npcId} current knot to: ${config.knot}`);
}
// Debug: Log the full config to see what we're working with
console.log(`🔍 Event config for ${eventPattern}:`, {
conversationMode: config.conversationMode,
npcType: npc.npcType,
knot: config.knot,
fullConfig: config
});
// Check if this event should trigger a full person-chat conversation
// instead of just a bark (indicated by conversationMode: 'person-chat')
if (config.conversationMode === 'person-chat' && npc.npcType === 'person') {
console.log(`👤 Starting person-chat conversation for NPC ${npcId}`);
// Close any currently running minigame (like lockpicking) first
if (window.MinigameFramework && window.MinigameFramework.currentMinigame) {
console.log(`🛑 Closing currently running minigame before starting person-chat`);
window.MinigameFramework.endMinigame(false, null);
console.log(`✅ Closed current minigame`);
}
// Start the person-chat minigame
if (window.MinigameFramework) {
console.log(`✅ Starting person-chat minigame for ${npcId}`);
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: npc.id,
startKnot: config.knot || npc.currentKnot,
scenario: window.gameScenario
});
console.log(`[NPCManager] Event '${eventPattern}' triggered for NPC '${npcId}' → person-chat conversation`);
return; // Exit early - person-chat is handling it
} else {
console.warn(`⚠️ MinigameFramework not available for person-chat`);
}
}
// If bark text is provided, show it directly
if (this.barkSystem && (config.bark || config.message)) {
const barkText = config.bark || config.message;
@@ -743,6 +877,106 @@ export default class NPCManager {
this.storyCache.clear();
console.log(`[NPCManager] Cleared all caches`);
}
/**
* Enable or disable LOS cone visualization for debugging
* @param {boolean} enable - Whether to show LOS cones
* @param {Phaser.Scene} scene - Phaser scene for drawing
*/
setLOSVisualization(enable, scene = null) {
this.losVisualizationEnabled = enable;
if (enable && scene) {
console.log('👁️ Enabling LOS visualization');
this._updateLOSVisualizations(scene);
} else if (!enable) {
console.log('👁️ Disabling LOS visualization');
this._clearLOSVisualizations();
}
}
/**
* Update LOS visualizations for all NPCs in a scene
* Call this from the game loop (update method) if visualization is enabled
* @param {Phaser.Scene} scene - Phaser scene for drawing
*/
updateLOSVisualizations(scene) {
if (!this.losVisualizationEnabled || !scene) return;
this._updateLOSVisualizations(scene);
}
/**
* Internal: Update or create LOS cone graphics
*/
_updateLOSVisualizations(scene) {
console.log(`🎯 Updating LOS visualizations for ${this.npcs.size} NPCs`);
let visualizedCount = 0;
for (const npc of this.npcs.values()) {
// Only visualize person-type NPCs with LOS config
if (npc.npcType !== 'person') {
console.log(` Skip "${npc.id}" - not person type (${npc.npcType})`);
continue;
}
if (!npc.los || !npc.los.enabled) {
console.log(` Skip "${npc.id}" - no LOS config or disabled`);
continue;
}
console.log(` Processing "${npc.id}" - has LOS config`, npc.los);
// Remove old visualization
if (this.losVisualizations.has(npc.id)) {
console.log(` Clearing old visualization for "${npc.id}"`);
clearLOSCone(this.losVisualizations.get(npc.id));
}
// Draw new cone (depth is set inside drawLOSCone)
const graphics = drawLOSCone(scene, npc, npc.los, 0x00ff00, 0.15);
if (graphics) {
this.losVisualizations.set(npc.id, graphics);
// Graphics depth is already set inside drawLOSCone to -999
console.log(` ✅ Created visualization for "${npc.id}"`);
visualizedCount++;
} else {
console.log(` ❌ Failed to create visualization for "${npc.id}"`);
}
}
console.log(`✅ LOS visualization update complete: ${visualizedCount}/${this.npcs.size} visualized`);
}
/**
* Internal: Clear all LOS visualizations
*/
_clearLOSVisualizations() {
for (const graphics of this.losVisualizations.values()) {
clearLOSCone(graphics);
}
this.losVisualizations.clear();
}
/**
* Cleanup: destroy all LOS visualizations and event listeners
*/
destroy() {
this._clearLOSVisualizations();
this.stopTimedMessages();
// Clear all event listeners
for (const listeners of this.eventListeners.values()) {
listeners.forEach(({ listener }) => {
if (this.eventDispatcher && typeof listener === 'function') {
this.eventDispatcher.off('*', listener);
}
});
}
this.eventListeners.clear();
console.log('[NPCManager] Destroyed');
}
}
// Console helper for debugging

View File

@@ -108,6 +108,32 @@ export function handleUnlock(lockable, type) {
} else if (hasLockpick) {
// Only lockpick available - launch lockpicking minigame directly
console.log('LOCKPICK AVAILABLE - STARTING LOCKPICKING MINIGAME');
// CHECK: Should any NPC interrupt with person-chat instead?
const roomId = lockable.doorProperties?.roomId || window.currentRoomId;
if (window.npcManager && roomId) {
// Get player position for LOS check
const playerPos = window.player?.sprite?.getCenter ?
window.player.sprite.getCenter() :
{ x: window.player?.x || 0, y: window.player?.y || 0 };
const interruptingNPC = window.npcManager.shouldInterruptLockpickingWithPersonChat(roomId, playerPos);
if (interruptingNPC) {
console.log(`🚫 LOCKPICKING INTERRUPTED: Triggering person-chat with NPC "${interruptingNPC.id}"`);
// Trigger the lockpick event which will start person-chat
if (window.npcManager.eventDispatcher) {
window.npcManager.eventDispatcher.emit('lockpick_used_in_view', {
npcId: interruptingNPC.id,
roomId: roomId,
lockable: lockable,
timestamp: Date.now()
});
}
return; // Don't start lockpicking minigame
}
}
let difficulty = lockable.doorProperties?.difficulty || lockable.scenarioData?.difficulty || lockable.properties?.difficulty || lockRequirements.difficulty || 'medium';
// Check for both keyPins (camelCase) and key_pins (snake_case)
let keyPins = lockable.doorProperties?.keyPins || lockable.doorProperties?.key_pins ||

View File

@@ -0,0 +1,198 @@
// security-guard.ink
// A security guard that patrols the corridor
// Reacts when the player attempts to lockpick in their view
// Can be persuaded to let the player off or confronted with consequences
// Uses hub pattern for clear conversation flow
VAR influence = 0
VAR caught_lockpicking = false
VAR confrontation_attempts = 0
VAR warned_player = false
=== start ===
# speaker:security_guard
{not warned_player:
# display:guard-patrol
You see the guard patrolling back and forth. They're watching the area carefully.
~ warned_player = true
What brings you to this corridor?
}
{warned_player and not caught_lockpicking:
# display:guard-patrol
The guard nods at you as they continue their patrol.
What do you want?
}
-> hub
=== hub ===
+ [I'm just passing through]
-> passing_through
+ [I need to access that door]
-> request_access
+ [Nothing, just leaving]
#exit_conversation
# speaker:security_guard
Good. Stay out of trouble.
-> hub
=== on_lockpick_used ===
# speaker:security_guard
{caught_lockpicking < 1:
~ caught_lockpicking = true
~ confrontation_attempts = 0
}
~ confrontation_attempts++
# display:guard-confrontation
{confrontation_attempts == 1:
Hey! What do you think you're doing with that lock?
* [I was just... looking for something I dropped]
-> explain_drop
* [This is official business]
-> claim_official
* [I can explain...]
-> explain_situation
* [Mind your own business]
-> hostile_response
}
{confrontation_attempts > 1:
I already told you to stop! This is your final warning.
* [Okay, I'm leaving right now]
-> back_down
* [You can't tell me what to do]
-> escalate_conflict
}
=== explain_drop ===
# speaker:security_guard
{influence >= 30:
~ influence -= 10
Looking for something... sure. Well, I don't get paid enough to care too much.
Just make it quick and don't let me catch you again.
# display:guard-annoyed
-> hub
}
{influence < 30:
~ influence -= 15
That's a pretty thin excuse. I'm going to have to report this incident.
Move along before I call for backup.
# display:guard-hostile
-> END
}
=== claim_official ===
# speaker:security_guard
{influence >= 40:
~ influence -= 5
Official, huh? You look like you might belong here. Fine. But I'm watching.
# display:guard-neutral
-> hub
}
{influence < 40:
~ influence -= 20
Official? I don't recognize your clearance. Security protocol requires me to log this.
You're coming with me to speak with my supervisor.
# display:guard-alert
-> END
}
=== explain_situation ===
# speaker:security_guard
{influence >= 25:
~ influence -= 5
I'm listening. Make it quick.
* [I need to access critical files for the investigation]
-> explain_files
* [I'm security testing your protocols]
-> explain_audit
* [Actually, just let me go]
-> back_down
}
{influence < 25:
~ influence -= 20
No explanations. Security breach detected. This is being reported.
# display:guard-arrest
-> END
}
=== explain_files ===
# speaker:security_guard
{influence >= 35:
~ influence -= 10
Critical files need a key. Do you have one? If not, this conversation is over.
# display:guard-sympathetic
-> hub
}
{influence < 35:
~ influence -= 15
Critical files are locked for a reason. You don't have the clearance.
# display:guard-hostile
-> END
}
=== explain_audit ===
# speaker:security_guard
{influence >= 45:
~ influence -= 5
Security audit? You just exposed our weakest point. Congratulations.
But you need to leave now before someone else sees this.
# display:guard-amused
-> hub
}
{influence < 45:
~ influence -= 20
An audit would be scheduled and documented. This isn't.
# display:guard-alert
-> END
}
=== hostile_response ===
# speaker:security_guard
~ influence -= 30
That's it. You just made a big mistake.
SECURITY! CODE VIOLATION IN THE CORRIDOR!
# display:guard-aggressive
-> END
=== escalate_conflict ===
# speaker:security_guard
~ influence -= 40
You've crossed the line! This is a lockdown!
INTRUDER ALERT! INTRUDER ALERT!
# display:guard-alarm
-> END
=== back_down ===
# speaker:security_guard
{influence >= 15:
~ influence -= 5
Smart move. Now get out of here and don't come back.
# display:guard-neutral
}
{influence < 15:
Good thinking. But I've got a full description now.
# display:guard-watchful
}
-> END
=== passing_through ===
# speaker:security_guard
Just passing through, huh? Keep it that way. No trouble.
# display:guard-neutral
-> hub
=== request_access ===
# speaker:security_guard
{influence >= 50:
You? Access to that door? That's above your pay grade, friend.
But I like the confidence. Not happening though.
}
{influence < 50:
Access? Not without proper credentials. Nice try though.
}
# display:guard-skeptical
-> hub

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,149 @@
{
"scenario_brief": "Test scenario for NPC patrol and lockpick detection",
"endGoal": "Test NPC line-of-sight detection and lockpicking interruption",
"startRoom": "patrol_corridor",
"player": {
"id": "player",
"displayName": "Agent 0x00",
"spriteSheet": "hacker",
"spriteTalk": "assets/characters/hacker-talk.png",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
}
},
"rooms": {
"patrol_corridor": {
"type": "room_office",
"connections": {
"north": "secure_vault"
},
"npcs": [
{
"id": "patrol_with_face",
"displayName": "Patrol + Face Player",
"npcType": "person",
"position": { "x": 5, "y": 5 },
"spriteSheet": "hacker",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/security-guard.json",
"currentKnot": "start",
"behavior": {
"facePlayer": true,
"facePlayerDistance": 96,
"patrol": {
"enabled": true,
"speed": 100,
"changeDirectionInterval": 4000,
"bounds": {
"x": 128,
"y": 128,
"width": 128,
"height": 128
}
}
},
"los": {
"enabled": true,
"range": 250,
"angle": 120,
"visualize": true
},
"eventMappings": [
{
"eventPattern": "lockpick_used_in_view",
"targetKnot": "on_lockpick_used",
"conversationMode": "person-chat",
"cooldown": 0
}
],
"_comment": "Patrols normally, but stops to face player when within 3 tiles. Can see player within 250px at 120° FOV"
},
{
"id": "security_guard",
"displayName": "Security Guard",
"npcType": "person",
"position": { "x": 5, "y": 4 },
"spriteSheet": "hacker-red",
"spriteTalk": "assets/characters/hacker-red-talk.png",
"spriteConfig": {
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/security-guard.json",
"currentKnot": "start",
"behavior": {
"patrol": {
"enabled": true,
"route": [
{ "x": 2, "y": 3 },
{ "x": 8, "y": 3 },
{ "x": 8, "y": 6 },
{ "x": 2, "y": 6 }
],
"speed": 40,
"pauseTime": 10
}
},
"los": {
"enabled": true,
"range": 300,
"angle": 140,
"visualize": true
},
"eventMappings": [
{
"eventPattern": "lockpick_used_in_view",
"targetKnot": "on_lockpick_used",
"conversationMode": "person-chat",
"cooldown": 0
}
],
"_comment": "Follows route patrol, detects player within 300px at 140° FOV"
}
],
"objects": [
{
"type": "lockpick",
"name": "Lock Pick Set",
"takeable": true,
"observations": "A complete lock picking set with various picks and tension wrenches"
}
]
},
"secure_vault": {
"type": "room_office",
"connections": {
"south": "patrol_corridor"
},
"locked": true,
"lockType": "key",
"requires": "vault_key",
"keyPins": [75, 30, 50, 100],
"difficulty": "medium",
"objects": [
{
"type": "notes",
"name": "Classified Files",
"takeable": true,
"readable": true,
"text": "These files contain sensitive security protocols and encryption keys. Do not leave unattended.",
"observations": "Highly classified files stored in the secure vault"
},
{
"type": "key",
"name": "Vault Key",
"takeable": true,
"key_id": "vault_key",
"keyPins": [75, 30, 50, 100],
"observations": "A key that unlocks the secure vault door"
}
]
}
}
}

116
test-los-visualization.html Normal file
View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOS Visualization Test</title>
<link rel="stylesheet" href="css/main.css?v=1">
<link rel="stylesheet" href="css/minigames-framework.css?v=1">
<link rel="stylesheet" href="css/lockpicking.css?v=1">
<link rel="stylesheet" href="css/password-minigame.css?v=1">
<link rel="stylesheet" href="css/pin.css?v=1">
<link rel="stylesheet" href="css/biometrics-minigame.css?v=1">
<link rel="stylesheet" href="css/bluetooth-scanner.css?v=1">
<link rel="stylesheet" href="css/phone-chat-minigame.css?v=1">
<link rel="stylesheet" href="css/person-chat-minigame.css?v=1">
<link rel="stylesheet" href="css/inventory.css?v=1">
<link rel="stylesheet" href="css/notifications.css?v=1">
<link rel="stylesheet" href="css/modals.css?v=1">
<link rel="stylesheet" href="css/panels.css?v=1">
<link rel="stylesheet" href="css/npc-interactions.css?v=1">
<link rel="stylesheet" href="css/npc-barks.css?v=1">
<link rel="stylesheet" href="css/text-file-minigame.css?v=1">
<link rel="stylesheet" href="css/container-minigame.css?v=1">
<link rel="stylesheet" href="css/dusting.css?v=1">
<link rel="stylesheet" href="css/notes.css?v=1">
<style>
body {
margin: 0;
padding: 0;
background: #1a1a1a;
font-family: Arial, sans-serif;
}
#debug-panel {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.9);
color: #00ff00;
padding: 10px;
border: 2px solid #00ff00;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
z-index: 9999;
max-width: 300px;
max-height: 200px;
overflow: auto;
}
.debug-line {
margin: 5px 0;
padding: 2px;
}
button {
background: #00ff00;
color: #000;
border: 2px solid #00ff00;
padding: 8px 12px;
margin: 5px;
cursor: pointer;
font-weight: bold;
}
button:hover {
background: #00cc00;
border-color: #00cc00;
}
</style>
</head>
<body>
<div id="game-container"></div>
<div id="debug-panel">
<div class="debug-line">📡 LOS Debug Panel</div>
<div class="debug-line">
<button onclick="window.enableLOS()">Enable LOS</button>
<button onclick="window.disableLOS()">Disable LOS</button>
</div>
<div id="debug-status" class="debug-line">Loading...</div>
</div>
<!-- Phaser library -->
<script src="assets/vendor/phaser.js?v=1"></script>
<!-- EasyStar pathfinding -->
<script src="assets/vendor/easystar.js?v=1"></script>
<!-- CyberChef -->
<script src="assets/cyberchef/CyberChef.js?v=1"></script>
<!-- Ink -->
<script src="assets/vendor/ink.js"></script>
<!-- Main game script (with los flag in URL) -->
<script type="module">
// Force LOS visualization on load
window.location.search = '?los=1';
import('./js/main.js?v=1').then(() => {
console.log('✅ Game loaded');
// Update debug panel
setInterval(() => {
const status = document.getElementById('debug-status');
if (window.npcManager) {
const enabled = window.npcManager.losVisualizationEnabled;
const vizCount = window.npcManager.losVisualizations?.size || 0;
const npcCount = window.npcManager.npcs?.size || 0;
status.innerHTML = `
<div>Status: ${enabled ? '✅ ENABLED' : '❌ DISABLED'}</div>
<div>NPCs: ${npcCount}</div>
<div>Visualized: ${vizCount}</div>
`;
}
}, 500);
});
</script>
</body>
</html>