diff --git a/js/core/game.js b/js/core/game.js index 496de93..fdfb870 100644 --- a/js/core/game.js +++ b/js/core/game.js @@ -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); diff --git a/js/core/rooms.js b/js/core/rooms.js index c3a0a65..37be7d1 100644 --- a/js/core/rooms.js +++ b/js/core/rooms.js @@ -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}`); + } } /** diff --git a/js/main.js b/js/main.js index defa0a6..4711056 100644 --- a/js/main.js +++ b/js/main.js @@ -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); } diff --git a/js/systems/npc-los.js b/js/systems/npc-los.js new file mode 100644 index 0000000..f1b3a4e --- /dev/null +++ b/js/systems/npc-los.js @@ -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(); + } +} diff --git a/js/systems/npc-manager.js b/js/systems/npc-manager.js index b1ee3c4..d15fb44 100644 --- a/js/systems/npc-manager.js +++ b/js/systems/npc-manager.js @@ -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(`οΏ½π« 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 diff --git a/js/systems/unlock-system.js b/js/systems/unlock-system.js index 1a24fce..9983b3d 100644 --- a/js/systems/unlock-system.js +++ b/js/systems/unlock-system.js @@ -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 || diff --git a/planning_notes/rails-engine-migration/IMPLEMENTATION_SUMMARY.md b/planning_notes/rails-engine-migration/progress/IMPLEMENTATION_SUMMARY.md similarity index 100% rename from planning_notes/rails-engine-migration/IMPLEMENTATION_SUMMARY.md rename to planning_notes/rails-engine-migration/progress/IMPLEMENTATION_SUMMARY.md diff --git a/scenarios/ink/security-guard.ink b/scenarios/ink/security-guard.ink new file mode 100644 index 0000000..838fe70 --- /dev/null +++ b/scenarios/ink/security-guard.ink @@ -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 diff --git a/scenarios/ink/security-guard.json b/scenarios/ink/security-guard.json new file mode 100644 index 0000000..c7077de --- /dev/null +++ b/scenarios/ink/security-guard.json @@ -0,0 +1 @@ +{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["#","^speaker:security_guard","/#","ev",{"VAR?":"warned_player"},"!","/ev",[{"->":".^.b","c":true},{"b":["\n","#","^display:guard-patrol","/#","^You see the guard patrolling back and forth. They're watching the area carefully.","\n","ev",true,"/ev",{"VAR=":"warned_player","re":true},"^What brings you to this corridor?","\n",{"->":"start.8"},null]}],"nop","\n","ev",{"VAR?":"warned_player"},{"VAR?":"caught_lockpicking"},"!","&&","/ev",[{"->":".^.b","c":true},{"b":["\n","#","^display:guard-patrol","/#","^The guard nods at you as they continue their patrol.","\n","^What do you want?","\n",{"->":"start.17"},null]}],"nop","\n",{"->":"hub"},null],"hub":[["ev","str","^I'm just passing through","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^I need to access that door","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Nothing, just leaving","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["^ ","\n",{"->":"passing_through"},null],"c-1":["\n",{"->":"request_access"},null],"c-2":["\n","#","^exit_conversation","/#","#","^speaker:security_guard","/#","^Good. Stay out of trouble.","\n",{"->":"hub"},null]}],null],"on_lockpick_used":["#","^speaker:security_guard","/#","ev",{"VAR?":"caught_lockpicking"},1,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",true,"/ev",{"VAR=":"caught_lockpicking","re":true},"ev",0,"/ev",{"VAR=":"confrontation_attempts","re":true},{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"confrontation_attempts"},1,"+",{"VAR=":"confrontation_attempts","re":true},"/ev","#","^display:guard-confrontation","/#","ev",{"VAR?":"confrontation_attempts"},1,"==","/ev",[{"->":".^.b","c":true},{"b":["\n","^Hey! What do you think you're doing with that lock?","\n","ev","str","^I was just... looking for something I dropped","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^This is official business","/str","/ev",{"*":".^.c-1","flg":20},"ev","str","^I can explain...","/str","/ev",{"*":".^.c-2","flg":20},"ev","str","^Mind your own business","/str","/ev",{"*":".^.c-3","flg":20},{"->":".^.^.^.26"},{"c-0":["\n",{"->":"explain_drop"},{"#f":5}],"c-1":["\n",{"->":"claim_official"},{"#f":5}],"c-2":["\n",{"->":"explain_situation"},{"#f":5}],"c-3":["\n",{"->":"hostile_response"},{"#f":5}]}]}],"nop","\n","ev",{"VAR?":"confrontation_attempts"},1,">","/ev",[{"->":".^.b","c":true},{"b":["\n","^I already told you to stop! This is your final warning.","\n","ev","str","^Okay, I'm leaving right now","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^You can't tell me what to do","/str","/ev",{"*":".^.c-1","flg":20},{"->":".^.^.^.34"},{"c-0":["\n",{"->":"back_down"},{"#f":5}],"c-1":["\n",{"->":"escalate_conflict"},{"#f":5}]}]}],"nop","\n",null],"explain_drop":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},30,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},10,"-",{"VAR=":"influence","re":true},"/ev","^Looking for something... sure. Well, I don't get paid enough to care too much.","\n","^Just make it quick and don't let me catch you again.","\n","#","^display:guard-annoyed","/#",{"->":"hub"},{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},30,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},15,"-",{"VAR=":"influence","re":true},"/ev","^That's a pretty thin excuse. I'm going to have to report this incident.","\n","^Move along before I call for backup.","\n","#","^display:guard-hostile","/#","end",{"->":".^.^.^.17"},null]}],"nop","\n",null],"claim_official":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},40,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},5,"-",{"VAR=":"influence","re":true},"/ev","^Official, huh? You look like you might belong here. Fine. But I'm watching.","\n","#","^display:guard-neutral","/#",{"->":"hub"},{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},40,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},20,"-",{"VAR=":"influence","re":true},"/ev","^Official? I don't recognize your clearance. Security protocol requires me to log this.","\n","^You're coming with me to speak with my supervisor.","\n","#","^display:guard-alert","/#","end",{"->":".^.^.^.17"},null]}],"nop","\n",null],"explain_situation":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},25,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},5,"-",{"VAR=":"influence","re":true},"/ev","^I'm listening. Make it quick.","\n","ev","str","^I need to access critical files for the investigation","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^I'm security testing your protocols","/str","/ev",{"*":".^.c-1","flg":20},"ev","str","^Actually, just let me go","/str","/ev",{"*":".^.c-2","flg":20},{"->":".^.^.^.9"},{"c-0":["\n",{"->":"explain_files"},{"#f":5}],"c-1":["\n",{"->":"explain_audit"},{"#f":5}],"c-2":["\n",{"->":"back_down"},{"#f":5}]}]}],"nop","\n","ev",{"VAR?":"influence"},25,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},20,"-",{"VAR=":"influence","re":true},"/ev","^No explanations. Security breach detected. This is being reported.","\n","#","^display:guard-arrest","/#","end",{"->":".^.^.^.17"},null]}],"nop","\n",null],"explain_files":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},35,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},10,"-",{"VAR=":"influence","re":true},"/ev","^Critical files need a key. Do you have one? If not, this conversation is over.","\n","#","^display:guard-sympathetic","/#",{"->":"hub"},{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},35,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},15,"-",{"VAR=":"influence","re":true},"/ev","^Critical files are locked for a reason. You don't have the clearance.","\n","#","^display:guard-hostile","/#","end",{"->":".^.^.^.17"},null]}],"nop","\n",null],"explain_audit":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},45,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},5,"-",{"VAR=":"influence","re":true},"/ev","^Security audit? You just exposed our weakest point. Congratulations.","\n","^But you need to leave now before someone else sees this.","\n","#","^display:guard-amused","/#",{"->":"hub"},{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},45,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},20,"-",{"VAR=":"influence","re":true},"/ev","^An audit would be scheduled and documented. This isn't.","\n","#","^display:guard-alert","/#","end",{"->":".^.^.^.17"},null]}],"nop","\n",null],"hostile_response":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},30,"-",{"VAR=":"influence","re":true},"/ev","^That's it. You just made a big mistake.","\n","^SECURITY! CODE VIOLATION IN THE CORRIDOR!","\n","#","^display:guard-aggressive","/#","end",null],"escalate_conflict":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},40,"-",{"VAR=":"influence","re":true},"/ev","^You've crossed the line! This is a lockdown!","\n","^INTRUDER ALERT! INTRUDER ALERT!","\n","#","^display:guard-alarm","/#","end",null],"back_down":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},15,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","ev",{"VAR?":"influence"},5,"-",{"VAR=":"influence","re":true},"/ev","^Smart move. Now get out of here and don't come back.","\n","#","^display:guard-neutral","/#",{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},15,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","^Good thinking. But I've got a full description now.","\n","#","^display:guard-watchful","/#",{"->":".^.^.^.17"},null]}],"nop","\n","end",null],"passing_through":["#","^speaker:security_guard","/#","^Just passing through, huh? Keep it that way. No trouble.","\n","#","^display:guard-neutral","/#",{"->":"hub"},null],"request_access":["#","^speaker:security_guard","/#","ev",{"VAR?":"influence"},50,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","^You? Access to that door? That's above your pay grade, friend.","\n","^But I like the confidence. Not happening though.","\n",{"->":".^.^.^.9"},null]}],"nop","\n","ev",{"VAR?":"influence"},50,"<","/ev",[{"->":".^.b","c":true},{"b":["\n","^Access? Not without proper credentials. Nice try though.","\n",{"->":".^.^.^.17"},null]}],"nop","\n","#","^display:guard-skeptical","/#",{"->":"hub"},null],"global decl":["ev",0,{"VAR=":"influence"},false,{"VAR=":"caught_lockpicking"},0,{"VAR=":"confrontation_attempts"},false,{"VAR=":"warned_player"},"/ev","end",null]}],"listDefs":{}} \ No newline at end of file diff --git a/scenarios/npc-patrol-lockpick.json b/scenarios/npc-patrol-lockpick.json new file mode 100644 index 0000000..bbb92ac --- /dev/null +++ b/scenarios/npc-patrol-lockpick.json @@ -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" + } + ] + } + } +} diff --git a/test-los-visualization.html b/test-los-visualization.html new file mode 100644 index 0000000..384d768 --- /dev/null +++ b/test-los-visualization.html @@ -0,0 +1,116 @@ + + +
+ + +