diff --git a/assets/icons/heart-half.png b/assets/icons/heart-half.png new file mode 100644 index 0000000..184ffe2 Binary files /dev/null and b/assets/icons/heart-half.png differ diff --git a/assets/icons/heart.png b/assets/icons/heart.png new file mode 100644 index 0000000..b72f19c Binary files /dev/null and b/assets/icons/heart.png differ diff --git a/css/hud.css b/css/hud.css new file mode 100644 index 0000000..40f9912 --- /dev/null +++ b/css/hud.css @@ -0,0 +1,185 @@ +/* HUD (Heads-Up Display) System Styles */ +/* Combines Inventory and Health UI */ + +/* ===== HEALTH UI ===== */ + +#health-ui-container { + position: fixed; + bottom: 80px; /* Directly above inventory (which is 80px tall) */ + left: 50%; + transform: translateX(-50%); + z-index: 1100; + pointer-events: none; +} + +.health-ui-display { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #333; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.9), inset 0 0 5px rgba(0, 0, 0, 0.5); +} + +.health-heart { + width: 32px; + height: 32px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + transition: opacity 0.2s ease-in-out; + display: block; +} + +.health-heart:hover { + filter: drop-shadow(0 0 4px rgba(255, 0, 0, 0.6)); +} + +/* ===== INVENTORY UI ===== */ + +#inventory-container { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + height: 80px; + display: flex; + align-items: center; + padding: 0 20px; + z-index: 1000; + font-family: 'VT323'; +} + +#inventory-container::-webkit-scrollbar { + height: 8px; +} + +#inventory-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); +} + +#inventory-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 4px; +} + +.inventory-slot { + min-width: 60px; + height: 60px; + margin: 0 5px; + border: 1px solid rgba(255, 255, 255, 0.3); + display: flex; + justify-content: center; + align-items: center; + position: relative; + background: rgb(149 157 216 / 80%); +} + +/* Pulse animation for newly added items */ +@keyframes pulse-slot { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); + } + 50% { + transform: scale(1.5); + box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +} + +.inventory-slot.pulse { + animation: pulse-slot 0.6s ease-out; +} + +.inventory-item { + max-width: 48px; + max-height: 48px; + cursor: pointer; + transition: transform 0.2s; + transform: scale(2); + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; +} + +.inventory-item:hover { + transform: scale(2.2); +} + +.inventory-tooltip { + position: absolute; + bottom: 100%; + left: -10px; + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 18px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; +} + +.inventory-item:hover + .inventory-tooltip { + opacity: 1; +} + +/* Key ring specific styling */ +.inventory-item[data-type="key_ring"] { + position: relative; +} + +.inventory-item[data-type="key_ring"]::after { + content: attr(data-key-count); + position: absolute; + top: -5px; + right: -5px; + background: #ff6b6b; + color: white; + border-radius: 50%; + width: 18px; + height: 18px; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + border: 2px solid #fff; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +/* Hide count badge for single keys */ +.inventory-item[data-type="key_ring"][data-key-count="1"]::after { + display: none; +} + +/* Phone unread message badge */ +.inventory-slot { + position: relative; +} + +.inventory-slot .phone-badge { + display: block; + position: absolute; + top: -5px; + right: -5px; + background: #5fcf69; /* Green to match phone LCD screen */ + color: #000; + border: 2px solid #000; + min-width: 20px; + height: 20px; + padding: 0 4px; + line-height: 16px; /* Center text vertically (20px - 2px border * 2 = 16px) */ + text-align: center; + font-size: 12px; + font-weight: bold; + box-shadow: 0 2px 4px rgba(0,0,0,0.8); + z-index: 10; + border-radius: 0; /* Maintain pixel-art aesthetic */ +} diff --git a/index.html b/index.html index e01462a..8fa5d13 100644 --- a/index.html +++ b/index.html @@ -30,7 +30,7 @@ - + diff --git a/js/minigames/person-chat/person-chat-minigame.js b/js/minigames/person-chat/person-chat-minigame.js index b2695cc..793b23d 100644 --- a/js/minigames/person-chat/person-chat-minigame.js +++ b/js/minigames/person-chat/person-chat-minigame.js @@ -51,6 +51,7 @@ export class PersonChatMinigame extends MinigameScene { this.npcId = params.npcId; this.title = params.title || 'Conversation'; this.background = params.background; // Optional background image path from timedConversation + this.startKnot = params.startKnot; // Optional knot to jump to (used for event-triggered conversations) // Verify NPC exists const npc = this.npcManager.getNPC(this.npcId); @@ -308,28 +309,36 @@ export class PersonChatMinigame extends MinigameScene { return; } - // Restore previous conversation state if it exists - const stateRestored = npcConversationStateManager.restoreNPCState( - this.npcId, - this.inkEngine.story - ); + // If a startKnot was provided (event-triggered conversation), jump directly to it + // This skips state restoration and goes straight to the event response + if (this.startKnot) { + console.log(`โก Event-triggered conversation: jumping directly to knot: ${this.startKnot}`); + this.conversation.goToKnot(this.startKnot); + } else { + // Otherwise, restore previous conversation state if it exists + const stateRestored = npcConversationStateManager.restoreNPCState( + this.npcId, + this.inkEngine.story + ); + + if (stateRestored) { + // If we restored state, reset the story ended flag in case it was marked as ended before + this.conversation.storyEnded = false; + console.log(`๐ Continuing previous conversation with ${this.npcId}`); + } else { + // First time conversation - navigate to start knot + const startKnot = this.npc.currentKnot || 'start'; + this.conversation.goToKnot(startKnot); + console.log(`๐ Starting new conversation with ${this.npcId}`); + } + } // Always sync global variables to ensure they're up to date // This is important because other NPCs may have changed global variables if (this.inkEngine && this.inkEngine.story) { npcConversationStateManager.syncGlobalVariablesToStory(this.inkEngine.story); } - - if (stateRestored) { - // If we restored state, reset the story ended flag in case it was marked as ended before - this.conversation.storyEnded = false; - console.log(`๐ Continuing previous conversation with ${this.npcId}`); - } else { - // First time conversation - navigate to start knot - const startKnot = this.npc.currentKnot || 'start'; - this.conversation.goToKnot(startKnot); - console.log(`๐ Starting new conversation with ${this.npcId}`); - } + // Re-sync global variables right before showing dialogue to ensure conditionals are evaluated with current values // This is critical because Ink evaluates conditionals when continue() is called @@ -872,6 +881,68 @@ export class PersonChatMinigame extends MinigameScene { }, 1000); } + /** + * Jump to a specific knot in the conversation while keeping the minigame active + * Called when an event (like lockpicking) is detected during an active conversation + * @param {string} knotName - Name of the knot to jump to + */ + jumpToKnot(knotName) { + if (!knotName) { + console.warn('jumpToKnot: No knot name provided'); + return false; + } + + if (!this.conversation || !this.conversation.engine || !this.conversation.engine.story) { + console.warn('jumpToKnot: Conversation engine not initialized', { + hasConversation: !!this.conversation, + hasEngine: !!this.conversation?.engine, + hasStory: !!this.conversation?.engine?.story + }); + return false; + } + + try { + console.log(`๐ฏ PersonChatMinigame.jumpToKnot() - Starting jump to: ${knotName}`); + console.log(` Current NPC: ${this.npcId}`); + console.log(` Current knot before jump: ${this.conversation.engine.story.state?.currentPathString}`); + + // Use the conversation's goToKnot method instead of directly calling inkEngine + // This ensures NPC state is updated properly + const jumpSuccess = this.conversation.goToKnot(knotName); + + if (!jumpSuccess) { + console.error(`โ conversation.goToKnot() returned false for knot: ${knotName}`); + return false; + } + + console.log(` Knot after jump: ${this.conversation.engine.story.state?.currentPathString}`); + + // Clear any pending callbacks since we're changing the story + if (this.autoAdvanceTimer) { + clearTimeout(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + console.log(` Cleared auto-advance timer`); + } + this.pendingContinueCallback = null; + + // Clear the UI before showing new content + this.ui.hideChoices(); + console.log(` Hidden choice buttons`); + + console.log(`๐ฏ About to call showCurrentDialogue() to fetch new content...`); + + // Show the new dialogue at the target knot + // This will call conversation.continue() to get the content at the new knot + this.showCurrentDialogue(); + + console.log(`โ Successfully jumped to knot: ${knotName}`); + return true; + } catch (error) { + console.error(`โ Error jumping to knot ${knotName}:`, error); + return false; + } + } + /** * Override cleanup to ensure conversation state is saved * This is called by the base class before the minigame is removed diff --git a/js/systems/interactions.js b/js/systems/interactions.js index 90ff578..2809fb9 100644 --- a/js/systems/interactions.js +++ b/js/systems/interactions.js @@ -168,13 +168,19 @@ export function checkObjectInteractions() { if (distanceSq <= INTERACTION_RANGE_SQ) { if (!obj.isHighlighted) { obj.isHighlighted = true; - obj.setTint(0x4da6ff); // Blue tint for interactable objects + // Only apply tint if this is a sprite (has setTint method) + if (obj.setTint && typeof obj.setTint === 'function') { + obj.setTint(0x4da6ff); // Blue tint for interactable objects + } // Add interaction indicator sprite addInteractionIndicator(obj); } } else if (obj.isHighlighted) { obj.isHighlighted = false; - obj.clearTint(); + // Only clear tint if this is a sprite + if (obj.clearTint && typeof obj.clearTint === 'function') { + obj.clearTint(); + } // Clean up interaction sprite if exists if (obj.interactionIndicator) { obj.interactionIndicator.destroy(); @@ -279,6 +285,9 @@ export function checkObjectInteractions() { return; } + // Check if NPC is hostile - don't show talk icon if so + const isNPCHostile = sprite.npcId && window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(sprite.npcId); + // Use squared distance for performance const distanceSq = getInteractionDistance(player, sprite.x, sprite.y); @@ -289,18 +298,25 @@ export function checkObjectInteractions() { if (!sprite.interactionIndicator) { addInteractionIndicator(sprite); } - // Show talk icon and don't apply tint - icon provides visual feedback - if (sprite.interactionIndicator) { + // Show talk icon only if NPC is NOT hostile + if (sprite.interactionIndicator && !isNPCHostile) { sprite.interactionIndicator.setVisible(true); sprite.talkIconVisible = true; + } else if (sprite.interactionIndicator && isNPCHostile) { + sprite.interactionIndicator.setVisible(false); + sprite.talkIconVisible = false; } - } else if (sprite.interactionIndicator && !sprite.talkIconVisible) { + } else if (sprite.interactionIndicator && !sprite.talkIconVisible && !isNPCHostile) { // Update position of talk icon to stay pixel-perfect on NPC const iconX = Math.round(sprite.x + 5); const iconY = Math.round(sprite.y - 38); sprite.interactionIndicator.setPosition(iconX, iconY); sprite.interactionIndicator.setVisible(true); sprite.talkIconVisible = true; + } else if (isNPCHostile && sprite.interactionIndicator && sprite.talkIconVisible) { + // Hide icon if NPC became hostile + sprite.interactionIndicator.setVisible(false); + sprite.talkIconVisible = false; } } else if (sprite.isHighlighted) { sprite.isHighlighted = false; @@ -950,6 +966,9 @@ export function tryInteractWithNearest() { if (nearestObject.doorProperties) { // Handle door interaction - triggers unlock/open sequence based on lock state handleDoorInteraction(nearestObject); + } else if (nearestObject._isNPC) { + // Handle NPC interaction with hostile check + tryInteractWithNPC(nearestObject); } else { // Handle regular object interaction handleObjectInteraction(nearestObject); diff --git a/js/systems/npc-combat.js b/js/systems/npc-combat.js index 39a791d..63b640e 100644 --- a/js/systems/npc-combat.js +++ b/js/systems/npc-combat.js @@ -26,6 +26,11 @@ export class NPCCombat { return false; } + // Don't attack while a minigame is active (conversation, combat, etc.) + if (window.MinigameFramework && window.MinigameFramework.currentMinigame) { + return false; + } + // Check cooldown const lastAttackTime = this.npcAttackTimers.get(npcId) || 0; const now = Date.now(); diff --git a/js/systems/npc-game-bridge.js b/js/systems/npc-game-bridge.js index 8634fa9..5ee3710 100644 --- a/js/systems/npc-game-bridge.js +++ b/js/systems/npc-game-bridge.js @@ -472,6 +472,8 @@ export class NPCGameBridge { * @returns {Object} Result object with success status */ setNPCHostile(npcId, hostile) { + console.log(`๐ฎ npc-game-bridge.setNPCHostile called: ${npcId} โ ${hostile}`); + if (!window.npcBehaviorManager) { const result = { success: false, error: 'NPCBehaviorManager not initialized' }; this._logAction('setNPCHostile', { npcId, hostile }, result); @@ -481,6 +483,16 @@ export class NPCGameBridge { const behavior = window.npcBehaviorManager.getBehavior(npcId); if (behavior) { behavior.setState('hostile', hostile); + console.log(`๐ฎ Set behavior hostile for ${npcId}`); + + // Also update the hostile system to emit events and trigger health bars + if (window.npcHostileSystem) { + console.log(`๐ฎ Calling npcHostileSystem.setNPCHostile for ${npcId}`); + window.npcHostileSystem.setNPCHostile(npcId, hostile); + } else { + console.warn(`๐ฎ npcHostileSystem not found!`); + } + const result = { success: true, npcId, hostile }; this._logAction('setNPCHostile', { npcId, hostile }, result); return result; diff --git a/js/systems/npc-health-bar.js b/js/systems/npc-health-bar.js new file mode 100644 index 0000000..c3c870a --- /dev/null +++ b/js/systems/npc-health-bar.js @@ -0,0 +1,227 @@ +/** + * NPC Health Bar System + * Renders health bars above hostile NPCs in the Phaser scene + * + * @module npc-health-bar + */ + +export class NPCHealthBarManager { + constructor(scene) { + this.scene = scene; + this.healthBars = new Map(); // npcId -> graphics object + this.barConfig = { + width: 40, + height: 6, + offsetY: -50, // pixels above NPC + borderWidth: 1, + colors: { + background: 0x1a1a1a, + border: 0xcccccc, + health: 0x00ff00, + damage: 0xff0000 + } + }; + + console.log('โ NPC Health Bar Manager initialized'); + } + + /** + * Create a health bar for an NPC + * @param {string} npcId - The NPC ID + * @param {Object} npc - The NPC object with sprite and health properties + */ + createHealthBar(npcId, npc) { + if (this.healthBars.has(npcId)) { + console.warn(`Health bar already exists for NPC ${npcId}`); + return; + } + + // Get NPC current HP from hostile system + const hostileState = window.npcHostileSystem?.getNPCHostileState(npcId); + if (!hostileState) { + console.warn(`No hostile state found for NPC ${npcId}`); + return; + } + + const maxHP = hostileState.maxHP; + const currentHP = hostileState.currentHP; + + // Create graphics object for the health bar + const graphics = this.scene.make.graphics({ + x: npc.sprite.x, + y: npc.sprite.y + this.barConfig.offsetY, + add: true + }); + + // Set depth so bar appears above NPC + graphics.setDepth(npc.sprite.depth + 1); + + // Draw the health bar + this.drawHealthBar(graphics, currentHP, maxHP); + + // Store reference + this.healthBars.set(npcId, { + graphics, + npcId, + maxHP, + currentHP, + lastHP: currentHP + }); + + console.log(`๐ฅ Created health bar for NPC ${npcId}`); + } + + /** + * Draw the health bar graphics + * @param {Object} graphics - Phaser Graphics object + * @param {number} currentHP - Current HP value + * @param {number} maxHP - Maximum HP value + */ + drawHealthBar(graphics, currentHP, maxHP) { + const { width, height, borderWidth, colors } = this.barConfig; + + // Clear previous draw + graphics.clear(); + + // Draw background + graphics.fillStyle(colors.background, 1); + graphics.fillRect(-width / 2, -height / 2, width, height); + + // Draw border + graphics.lineStyle(borderWidth, colors.border, 1); + graphics.strokeRect(-width / 2, -height / 2, width, height); + + // Draw health fill + const healthRatio = Math.max(0, Math.min(1, currentHP / maxHP)); + const healthWidth = width * healthRatio; + + graphics.fillStyle(colors.health, 1); + graphics.fillRect(-width / 2, -height / 2, healthWidth, height); + + // Draw damage (red overlay if not full) + if (healthRatio < 1) { + graphics.fillStyle(colors.damage, 0.3); + graphics.fillRect(-width / 2 + healthWidth, -height / 2, width - healthWidth, height); + } + } + + /** + * Update health bar position and health value + * @param {string} npcId - The NPC ID + * @param {Object} npc - The NPC object with current position + * @param {number} currentHP - Current HP (optional, will fetch from hostile system if not provided) + */ + updateHealthBar(npcId, npc, currentHP = null) { + const barData = this.healthBars.get(npcId); + if (!barData) { + console.warn(`Health bar not found for NPC ${npcId}`); + return; + } + + // Get current HP from hostile system if not provided + if (currentHP === null) { + const hostileState = window.npcHostileSystem?.getNPCHostileState(npcId); + if (!hostileState) return; + currentHP = hostileState.currentHP; + } + + // Update position to follow NPC + barData.graphics.setPosition( + npc.sprite.x, + npc.sprite.y + this.barConfig.offsetY + ); + + // Update depth to keep above NPC + barData.graphics.setDepth(npc.sprite.depth + 1); + + // Update health if changed + if (currentHP !== barData.currentHP) { + barData.currentHP = currentHP; + this.drawHealthBar(barData.graphics, currentHP, barData.maxHP); + } + } + + /** + * Update all health bars (call from game update loop) + */ + updateAllHealthBars() { + for (const [npcId, barData] of this.healthBars) { + const npc = window.npcManager?.getNPC(npcId); + if (npc && npc.sprite) { + this.updateHealthBar(npcId, npc); + } + } + } + + /** + * Show a health bar (make it visible) + * @param {string} npcId - The NPC ID + */ + showHealthBar(npcId) { + const barData = this.healthBars.get(npcId); + if (barData) { + barData.graphics.setVisible(true); + } + } + + /** + * Hide a health bar (make it invisible) + * @param {string} npcId - The NPC ID + */ + hideHealthBar(npcId) { + const barData = this.healthBars.get(npcId); + if (barData) { + barData.graphics.setVisible(false); + } + } + + /** + * Remove a health bar completely + * @param {string} npcId - The NPC ID + */ + removeHealthBar(npcId) { + const barData = this.healthBars.get(npcId); + if (barData) { + barData.graphics.destroy(); + this.healthBars.delete(npcId); + console.log(`๐๏ธ Removed health bar for NPC ${npcId}`); + } + } + + /** + * Remove all health bars + */ + removeAllHealthBars() { + for (const [npcId, barData] of this.healthBars) { + barData.graphics.destroy(); + } + this.healthBars.clear(); + console.log('๐๏ธ Removed all health bars'); + } + + /** + * Get health bar for an NPC + * @param {string} npcId - The NPC ID + * @returns {Object|null} Health bar data or null + */ + getHealthBar(npcId) { + return this.healthBars.get(npcId) || null; + } + + /** + * Check if health bar exists for NPC + * @param {string} npcId - The NPC ID + * @returns {boolean} + */ + hasHealthBar(npcId) { + return this.healthBars.has(npcId); + } + + /** + * Destroy the manager and clean up + */ + destroy() { + this.removeAllHealthBars(); + console.log('๐๏ธ NPC Health Bar Manager destroyed'); + } +} diff --git a/js/systems/npc-hostile.js b/js/systems/npc-hostile.js index 93183a3..ce657e9 100644 --- a/js/systems/npc-hostile.js +++ b/js/systems/npc-hostile.js @@ -44,14 +44,19 @@ function setNPCHostile(npcId, isHostile) { const wasHostile = state.isHostile; state.isHostile = isHostile; - console.log(`NPC ${npcId} hostile: ${wasHostile} โ ${isHostile}`); + console.log(`โ๏ธ NPC ${npcId} hostile: ${wasHostile} โ ${isHostile}`); // Emit event if state changed if (wasHostile !== isHostile && window.eventDispatcher) { + console.log(`โ๏ธ Emitting NPC_HOSTILE_CHANGED for ${npcId} (isHostile=${isHostile})`); window.eventDispatcher.emit(CombatEvents.NPC_HOSTILE_CHANGED, { npcId, isHostile }); + } else if (wasHostile === isHostile) { + console.log(`โ๏ธ State unchanged for ${npcId} (already ${wasHostile}), skipping event`); + } else { + console.warn(`โ๏ธ Event dispatcher not found, cannot emit NPC_HOSTILE_CHANGED`); } return true; @@ -95,6 +100,9 @@ function damageNPC(npcId, amount) { window.spriteEffects.setKOAlpha(npc.sprite, 0.5); } + // Drop any items the NPC was holding + dropNPCItems(npcId); + if (window.eventDispatcher) { window.eventDispatcher.emit(CombatEvents.NPC_KO, { npcId }); } @@ -103,6 +111,161 @@ function damageNPC(npcId, amount) { return true; } +/** + * Drop items around a defeated NPC + * Items spawn at NPC location and are launched outward with physics + * They collide with walls, doors, and chairs so they stay in reach + * @param {string} npcId - The NPC that was defeated + */ +function dropNPCItems(npcId) { + const npc = window.npcManager?.getNPC(npcId); + if (!npc || !npc.itemsHeld || npc.itemsHeld.length === 0) { + return; + } + + // Find the NPC sprite and room to get its position + let npcSprite = null; + let npcRoomId = null; + if (window.rooms) { + for (const roomId in window.rooms) { + const room = window.rooms[roomId]; + if (!room.npcSprites) continue; + for (const sprite of room.npcSprites) { + if (sprite.npcId === npcId) { + npcSprite = sprite; + npcRoomId = roomId; + break; + } + } + if (npcSprite) break; + } + } + + if (!npcSprite || !npcRoomId) { + console.warn(`Could not find NPC sprite to drop items for ${npcId}`); + return; + } + + const room = window.rooms[npcRoomId]; + const gameRef = window.game; + const itemCount = npc.itemsHeld.length; + const launchSpeed = 200; // pixels per second + + npc.itemsHeld.forEach((item, index) => { + // Calculate angle around the NPC for each item + const angle = (index / itemCount) * Math.PI * 2; + + // All items spawn at NPC center location + const spawnX = Math.round(npcSprite.x); + const spawnY = Math.round(npcSprite.y); + + // Create actual Phaser sprite for the dropped item + const texture = item.texture || item.type || 'key'; + const spriteObj = gameRef.add.sprite(spawnX, spawnY, texture); + + // Set origin to match standard object creation + spriteObj.setOrigin(0, 0); + + // Create scenario data from the dropped item + const droppedItemData = { + ...item, + type: item.type || 'dropped_item', + name: item.name || 'Item', + takeable: true, + active: true, + visible: true, + interactable: true + }; + + // Apply scenario properties to sprite + spriteObj.scenarioData = droppedItemData; + spriteObj.interactable = true; + spriteObj.name = droppedItemData.name; + spriteObj.objectId = `dropped_${npcId}_${index}_${Date.now()}`; + spriteObj.takeable = true; + spriteObj.type = droppedItemData.type; + + // Copy over all properties from the item + Object.keys(droppedItemData).forEach(key => { + spriteObj[key] = droppedItemData[key]; + }); + + // Make the sprite interactive + spriteObj.setInteractive({ useHandCursor: true }); + + // Set up physics body for collision + gameRef.physics.add.existing(spriteObj); + spriteObj.body.setSize(24, 24); + spriteObj.body.setOffset(4, 4); + spriteObj.body.setBounce(0.3); // Reduced bounce + spriteObj.body.setFriction(0.99, 0.99); // High friction to stop movement + spriteObj.body.setDrag(0.99); // Drag coefficient to slow velocity + + // Launch item outward in the calculated angle + const velocityX = Math.cos(angle) * launchSpeed; + const velocityY = Math.sin(angle) * launchSpeed; + spriteObj.body.setVelocity(velocityX, velocityY); + + // Set a timer to completely stop the item after a short time + const stopDelay = 800; // Stop after 0.8 seconds + gameRef.time.delayedCall(stopDelay, () => { + if (spriteObj && spriteObj.body) { + spriteObj.body.setVelocity(0, 0); + spriteObj.body.setAcceleration(0, 0); + } + }); + + // Set up collisions with walls + if (room.wallCollisionBoxes) { + room.wallCollisionBoxes.forEach(wallBox => { + if (wallBox.body) { + gameRef.physics.add.collider(spriteObj, wallBox); + } + }); + } + + // Set up collisions with closed doors + if (room.doorSprites) { + room.doorSprites.forEach(doorSprite => { + if (doorSprite.body && doorSprite.body.immovable) { + gameRef.physics.add.collider(spriteObj, doorSprite); + } + }); + } + + // Set up collisions with chairs and other immovable objects + if (room.objects) { + Object.values(room.objects).forEach(obj => { + if (obj !== spriteObj && obj.body && obj.body.immovable) { + gameRef.physics.add.collider(spriteObj, obj); + } + }); + } + + // Set depth using the existing depth calculation method + // depth = objectBottomY + 0.5 + const objectBottomY = spriteObj.y + (spriteObj.height || 0); + const objectDepth = objectBottomY + 0.5; + spriteObj.setDepth(objectDepth); + + // Update depth each frame to follow Y position + const originalUpdate = spriteObj.update?.bind(spriteObj); + spriteObj.update = function() { + if (originalUpdate) originalUpdate(); + const newDepth = this.y + (this.height || 0) + 0.5; + this.setDepth(newDepth); + }; + + // Store in room.objects + room.objects[spriteObj.objectId] = spriteObj; + + console.log(`๐ง Dropped item ${droppedItemData.type} from ${npcId} at (${spawnX}, ${spawnY}), launching at angle ${(angle * 180 / Math.PI).toFixed(1)}ยฐ`); + }); + + // Clear the NPC's inventory + npc.itemsHeld = []; +} + function isNPCKO(npcId) { const state = npcHostileStates.get(npcId); return state ? state.isKO : false; diff --git a/js/systems/npc-los.js b/js/systems/npc-los.js index 552969c..8961318 100644 --- a/js/systems/npc-los.js +++ b/js/systems/npc-los.js @@ -232,32 +232,32 @@ export function drawLOSCone(scene, npc, losConfig = {}, color = 0x00ff00, alpha // 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, angle: ${angle}ยฐ`); + // console.log(`๐ข Drawing LOS cone for NPC at (${npcPos.x.toFixed(0)}, ${npcPos.y.toFixed(0)}), range: ${scaledRange}px, angle: ${angle}ยฐ`); const npcFacing = getNPCFacingDirection(npc); - console.log(` NPC facing: ${npcFacing.toFixed(0)}ยฐ`); + // console.log(` NPC facing: ${npcFacing.toFixed(0)}ยฐ`); // Offset cone origin to eye level (30% higher on the NPC sprite) const coneOriginY = npcPos.y - (npc._sprite?.height ?? 32) * 0.3; const coneOrigin = { x: npcPos.x, y: coneOriginY }; - console.log(` Cone origin at eye level: (${coneOrigin.x.toFixed(0)}, ${coneOrigin.y.toFixed(0)})`); + // console.log(` Cone origin at eye level: (${coneOrigin.x.toFixed(0)}, ${coneOrigin.y.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' - }); + // 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(coneOrigin.x, coneOrigin.y, scaledRange); - console.log(` โญ Range circle drawn at (${coneOrigin.x}, ${coneOrigin.y}) radius: ${scaledRange}`); + // console.log(` โญ Range circle drawn at (${coneOrigin.x}, ${coneOrigin.y}) radius: ${scaledRange}`); // Draw the cone fill with radial transparency gradient graphics.lineStyle(2, color, 0.2); @@ -380,15 +380,15 @@ export function drawLOSCone(scene, npc, losConfig = {}, color = 0x00ff00, alpha 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 - }); + // 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; } diff --git a/js/systems/npc-manager.js b/js/systems/npc-manager.js index d15fb44..8819683 100644 --- a/js/systems/npc-manager.js +++ b/js/systems/npc-manager.js @@ -352,7 +352,8 @@ export default class NPCManager { } // Check cooldown (in milliseconds, default 5000ms = 5s) - const cooldown = config.cooldown || 5000; + // IMPORTANT: Use ?? instead of || to properly handle cooldown: 0 + const cooldown = config.cooldown !== undefined && config.cooldown !== null ? config.cooldown : 5000; const now = Date.now(); if (triggered.lastTime && (now - triggered.lastTime < cooldown)) { const remainingMs = cooldown - (now - triggered.lastTime); @@ -407,7 +408,48 @@ export default class NPCManager { // 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}`); + console.log(`๐ค Handling person-chat for event on NPC ${npcId}`); + + // CHECK: Is a conversation already active with this NPC? + const currentConvNPCId = window.currentConversationNPCId; + const activeMinigame = window.MinigameFramework?.currentMinigame; + const isPersonChatActive = activeMinigame?.constructor?.name === 'PersonChatMinigame'; + const isConversationActive = currentConvNPCId === npcId; + + console.log(`๐ Event jump check:`, { + targetNpcId: npcId, + currentConvNPCId: currentConvNPCId, + isConversationActive: isConversationActive, + activeMinigame: activeMinigame?.constructor?.name || 'none', + isPersonChatActive: isPersonChatActive, + hasJumpToKnot: typeof activeMinigame?.jumpToKnot === 'function' + }); + + if (isConversationActive && isPersonChatActive) { + // JUMP TO KNOT in the active conversation instead of starting a new one + console.log(`โก Active conversation detected with ${npcId}, attempting jump to knot: ${config.knot}`); + + if (typeof activeMinigame.jumpToKnot === 'function') { + try { + const jumpSuccess = activeMinigame.jumpToKnot(config.knot); + if (jumpSuccess) { + console.log(`โ Successfully jumped to knot ${config.knot} in active conversation`); + return; // Success - exit early + } else { + console.warn(`โ ๏ธ Failed to jump to knot, falling back to new conversation`); + } + } catch (error) { + console.error(`โ Error during jumpToKnot: ${error.message}`); + } + } else { + console.warn(`โ ๏ธ jumpToKnot method not available on minigame`); + } + } else { + console.log(`โน๏ธ Not jumping: isConversationActive=${isConversationActive}, isPersonChatActive=${isPersonChatActive}`); + } + + // Not in an active conversation OR jump failed - start a new person-chat minigame + console.log(`๐ค Starting new person-chat conversation for NPC ${npcId}`); // Close any currently running minigame (like lockpicking) first if (window.MinigameFramework && window.MinigameFramework.currentMinigame) { @@ -910,26 +952,26 @@ export default class NPCManager { * Internal: Update or create LOS cone graphics */ _updateLOSVisualizations(scene) { - console.log(`๐ฏ Updating LOS visualizations for ${this.npcs.size} NPCs`); + // 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})`); + // 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`); + // console.log(` Skip "${npc.id}" - no LOS config or disabled`); continue; } - console.log(` Processing "${npc.id}" - has LOS config`, npc.los); + // 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}"`); + // console.log(` Clearing old visualization for "${npc.id}"`); clearLOSCone(this.losVisualizations.get(npc.id)); } @@ -938,14 +980,14 @@ export default class NPCManager { if (graphics) { this.losVisualizations.set(npc.id, graphics); // Graphics depth is already set inside drawLOSCone to -999 - console.log(` โ Created visualization for "${npc.id}"`); + // 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`); + // console.log(`โ LOS visualization update complete: ${visualizedCount}/${this.npcs.size} visualized`); } /** diff --git a/js/systems/player-combat.js b/js/systems/player-combat.js index 420d7f7..bcffcb1 100644 --- a/js/systems/player-combat.js +++ b/js/systems/player-combat.js @@ -87,7 +87,7 @@ export class PlayerCombat { * Applies AOE damage to all NPCs in punch range AND facing direction */ checkForHits() { - if (!window.player || !window.npcManager || !window.npcHostileSystem) { + if (!window.player) { return; } @@ -99,36 +99,48 @@ export class PlayerCombat { // Get player facing direction const direction = window.player.lastDirection || 'down'; - // Get all NPCs - const npcs = window.npcManager.getAllNPCs(); + // Get all NPCs from rooms let hitCount = 0; + + if (window.rooms) { + for (const roomId in window.rooms) { + const room = window.rooms[roomId]; + if (!room.npcSprites) continue; - npcs.forEach(npc => { - // Only damage hostile NPCs - if (!window.npcHostileSystem.isNPCHostile(npc.id)) { - return; + room.npcSprites.forEach(npcSprite => { + if (!npcSprite || !npcSprite.npcId) return; + + const npcId = npcSprite.npcId; + + // Only damage hostile NPCs + if (!window.npcHostileSystem.isNPCHostile(npcId)) { + return; + } + + // Don't damage NPCs that are already KO + if (window.npcHostileSystem.isNPCKO(npcId)) { + return; + } + + const npcX = npcSprite.x; + const npcY = npcSprite.y; + const distance = Phaser.Math.Distance.Between(playerX, playerY, npcX, npcY); + + if (distance > punchRange) { + return; // Too far + } + + // Check if NPC is in the facing direction + if (!this.isInDirection(playerX, playerY, npcX, npcY, direction)) { + return; // Not in facing direction + } + + // Hit landed! + this.applyDamage(npcId, punchDamage); + hitCount++; + }); } - - // Check if NPC is in range - if (!npc.sprite) return; - - const npcX = npc.sprite.x; - const npcY = npc.sprite.y; - const distance = Phaser.Math.Distance.Between(playerX, playerY, npcX, npcY); - - if (distance > punchRange) { - return; // Too far - } - - // Check if NPC is in the facing direction - if (!this.isInDirection(playerX, playerY, npcX, npcY, direction)) { - return; // Not in facing direction - } - - // Hit landed! - this.applyDamage(npc, punchDamage); - hitCount++; - }); + } // Check for chairs in range and direction let chairsHit = 0; @@ -198,23 +210,48 @@ export class PlayerCombat { /** * Apply damage to NPC - * @param {Object} npc - NPC object + * @param {string|Object} npcIdOrNPC - NPC ID string or NPC object * @param {number} damage - Damage amount */ - applyDamage(npc, damage) { + applyDamage(npcIdOrNPC, damage) { if (!window.npcHostileSystem) return; + // Get npcId + let npcId; + let npcSprite = null; + + if (typeof npcIdOrNPC === 'string') { + npcId = npcIdOrNPC; + // Find the sprite for this NPC + if (window.rooms) { + for (const roomId in window.rooms) { + const room = window.rooms[roomId]; + if (!room.npcSprites) continue; + for (const sprite of room.npcSprites) { + if (sprite.npcId === npcId) { + npcSprite = sprite; + break; + } + } + if (npcSprite) break; + } + } + } else { + npcId = npcIdOrNPC.id; + npcSprite = npcIdOrNPC.sprite; + } + // Apply damage - window.npcHostileSystem.damageNPC(npc.id, damage); + window.npcHostileSystem.damageNPC(npcId, damage); // Visual feedback - if (npc.sprite && window.spriteEffects) { - window.spriteEffects.flashDamage(npc.sprite); + if (npcSprite && window.spriteEffects) { + window.spriteEffects.flashDamage(npcSprite); } // Damage numbers - if (npc.sprite && window.damageNumbers) { - window.damageNumbers.show(npc.sprite.x, npc.sprite.y - 30, damage, 'damage'); + if (npcSprite && window.damageNumbers) { + window.damageNumbers.show(npcSprite.x, npcSprite.y - 30, damage, 'damage'); } // Screen shake (light) @@ -222,7 +259,7 @@ export class PlayerCombat { window.screenEffects.shakeNPCHit(); } - console.log(`Dealt ${damage} damage to ${npc.id}`); + console.log(`Dealt ${damage} damage to ${npcId}`); } /** diff --git a/js/ui/health-ui.js b/js/ui/health-ui.js index 12335f0..601d6c5 100644 --- a/js/ui/health-ui.js +++ b/js/ui/health-ui.js @@ -21,41 +21,21 @@ export class HealthUI { } createUI() { - // Create container div + // Create main container div this.container = document.createElement('div'); - this.container.id = 'health-ui'; - this.container.style.cssText = ` - position: fixed; - top: 60px; - left: 50%; - transform: translateX(-50%); - display: none; - z-index: 100; - padding: 10px; - background: rgba(0, 0, 0, 0.7); - border-radius: 8px; - border: 2px solid #444; - `; + this.container.id = 'health-ui-container'; // Create hearts container const heartsContainer = document.createElement('div'); - heartsContainer.style.cssText = ` - display: flex; - gap: 5px; - align-items: center; - `; + heartsContainer.id = 'health-ui'; + heartsContainer.className = 'health-ui-display'; // Create 5 heart slots for (let i = 0; i < COMBAT_CONFIG.ui.maxHearts; i++) { - const heart = document.createElement('div'); - heart.className = 'heart'; - heart.style.cssText = ` - width: 24px; - height: 24px; - font-size: 24px; - line-height: 24px; - `; - heart.textContent = 'โค๏ธ'; + const heart = document.createElement('img'); + heart.className = 'health-heart'; + heart.src = 'assets/icons/heart.png'; + heart.alt = 'HP'; heartsContainer.appendChild(heart); this.hearts.push(heart); } @@ -104,23 +84,23 @@ export class HealthUI { this.hearts.forEach((heart, index) => { if (index < fullHearts) { // Full heart - heart.textContent = 'โค๏ธ'; + heart.src = 'assets/icons/heart.png'; heart.style.opacity = '1'; } else if (index === fullHearts && halfHeart) { - // Half heart - use broken heart emoji - heart.textContent = '๐'; + // Half heart + heart.src = 'assets/icons/heart-half.png'; heart.style.opacity = '1'; } else { // Empty heart - heart.textContent = '๐ค'; - heart.style.opacity = '0.3'; + heart.src = 'assets/icons/heart.png'; + heart.style.opacity = '0.2'; } }); } show() { if (!this.isVisible) { - this.container.style.display = 'block'; + this.container.style.display = 'flex'; this.isVisible = true; } } diff --git a/js/ui/npc-health-bars.js b/js/ui/npc-health-bars.js index 0774293..2552a4d 100644 --- a/js/ui/npc-health-bars.js +++ b/js/ui/npc-health-bars.js @@ -22,13 +22,11 @@ export class NPCHealthBars { return; } - // Listen for NPC becoming hostile - window.eventDispatcher.on(CombatEvents.NPC_BECAME_HOSTILE, (data) => { - this.createHealthBar(data.npcId); - }); + console.log('๐ฅ NPCHealthBars: Setting up event listeners for', CombatEvents.NPC_HOSTILE_CHANGED); // Listen for NPC hostile state changes window.eventDispatcher.on(CombatEvents.NPC_HOSTILE_CHANGED, (data) => { + console.log('๐ฅ NPCHealthBars: Received NPC_HOSTILE_CHANGED event', { npcId: data.npcId, isHostile: data.isHostile }); if (data.isHostile) { this.createHealthBar(data.npcId); } else { @@ -38,6 +36,7 @@ export class NPCHealthBars { // Listen for NPC KO window.eventDispatcher.on(CombatEvents.NPC_KO, (data) => { + console.log('๐ฅ NPCHealthBars: Received NPC_KO event', data); this.removeHealthBar(data.npcId); }); } @@ -55,6 +54,20 @@ export class NPCHealthBars { return; } + // Get NPC health state + if (!window.npcHostileSystem) { + console.warn(`Cannot create health bar for ${npcId}: npcHostileSystem not found`); + return; + } + + const state = window.npcHostileSystem.getState(npcId); + if (!state) { + console.warn(`Cannot create health bar for ${npcId}: no hostile state found`); + return; + } + + console.log(`๐ฅ Creating health bar for ${npcId}, state:`, state); + const width = COMBAT_CONFIG.ui.healthBarWidth; const height = COMBAT_CONFIG.ui.healthBarHeight; const offsetY = COMBAT_CONFIG.ui.healthBarOffsetY; @@ -97,30 +110,30 @@ export class NPCHealthBars { // Get NPC health state if (!window.npcHostileSystem) return; const state = window.npcHostileSystem.getState(npcId); - if (!state) return; + if (!state) { + console.warn(`๐ฅ No state for ${npcId}`); + return; + } // Calculate HP percentage - const hpPercent = state.currentHP / state.maxHP; + const hpPercent = Math.max(0, state.currentHP / state.maxHP); + console.log(`๐ฅ Updating ${npcId}: HP=${state.currentHP}/${state.maxHP} (${Math.round(hpPercent * 100)}%)`); - // Update bar width + // Update bar width - shrinks from right side, stays anchored to left const maxWidth = COMBAT_CONFIG.ui.healthBarWidth; const currentWidth = maxWidth * hpPercent; healthBar.bar.setSize(currentWidth, COMBAT_CONFIG.ui.healthBarHeight); - // Shift bar position to keep it left-aligned - const offsetX = (maxWidth - currentWidth) / 2; - healthBar.bar.setX(healthBar.background.x - offsetX); + // Position bar so it stays left-aligned with background + // Background is centered at its position, so offset the bar by half the difference + const bgX = healthBar.background.x; + const bgLeftEdge = bgX - (maxWidth / 2); + const barCenterX = bgLeftEdge + (currentWidth / 2); + + healthBar.bar.setPosition(barCenterX, healthBar.background.y); - // Update color based on HP (green -> yellow -> red) - let color; - if (hpPercent > 0.5) { - color = 0x00ff00; // Green - } else if (hpPercent > 0.25) { - color = 0xffff00; // Yellow - } else { - color = 0xff0000; // Red - } - healthBar.bar.setFillStyle(color); + // Always use red for NPC health bar + healthBar.bar.setFillStyle(0xff0000); // Red } removeHealthBar(npcId) { @@ -157,14 +170,22 @@ export class NPCHealthBars { } getNPCSprite(npcId) { - // Try to get sprite from NPC manager - if (window.npcManager) { - const npc = window.npcManager.getNPC(npcId); - if (npc && npc.sprite) { - return npc.sprite; + // Search all rooms for this NPC's sprite + if (window.rooms) { + for (const roomId in window.rooms) { + const room = window.rooms[roomId]; + if (room.npcSprites) { + for (const sprite of room.npcSprites) { + if (sprite.npcId === npcId) { + console.log(`๐ฅ Found NPC sprite for ${npcId} in room ${roomId}`); + return sprite; + } + } + } } } + console.warn(`๐ฅ Could not find sprite for NPC: ${npcId}`); return null; } diff --git a/planning_notes/npc/hostile/implementation/COOLDOWN_ZERO_BUG_FIX.md b/planning_notes/npc/hostile/implementation/COOLDOWN_ZERO_BUG_FIX.md new file mode 100644 index 0000000..16233e4 --- /dev/null +++ b/planning_notes/npc/hostile/implementation/COOLDOWN_ZERO_BUG_FIX.md @@ -0,0 +1,107 @@ +# Bug Fix: Event Cooldown Zero Bug + +## The Problem + +When setting `"cooldown": 0` in an event mapping, the event would be treated as if cooldown was undefined and default to 5000ms (5 seconds). This prevented events from firing immediately. + +**Console output showed:** +``` +โธ๏ธ Event lockpick_used_in_view on cooldown (2904ms remaining) +``` + +Even though the scenario JSON had: +```json +{ + "eventMappings": [ + { + "eventPattern": "lockpick_used_in_view", + "targetKnot": "on_lockpick_used", + "conversationMode": "person-chat", + "cooldown": 0 // โ This should mean NO COOLDOWN + } + ] +} +``` + +## Root Cause + +**File:** `js/systems/npc-manager.js`, line 359 + +**Original code:** +```javascript +const cooldown = config.cooldown || 5000; +``` + +**The Issue:** +In JavaScript, `0` is a **falsy value**. So when `config.cooldown` is `0`: +- `0 || 5000` evaluates to `5000` (the `||` operator returns the first truthy value) +- This is called the "falsy coercion bug" + +## The Solution + +**Fixed code:** +```javascript +const cooldown = config.cooldown !== undefined && config.cooldown !== null ? config.cooldown : 5000; +``` + +**How it works:** +- Explicitly check if `config.cooldown` is defined and not null +- If it is defined (including `0`), use that value +- Only use the default `5000` if cooldown is actually undefined or null + +## Why This Matters + +The `||` operator works well for string/object defaults but fails for numeric falsy values like: +- `0` (zero) +- `false` +- Empty string `""` + +**Best practice:** When dealing with numeric configs, always check explicitly for undefined/null: +```javascript +// โ BAD - Won't work for 0, false, "" +const value = config.value || defaultValue; + +// โ GOOD - Works for all values including 0 +const value = config.value !== undefined ? config.value : defaultValue; + +// โ GOOD - Modern JavaScript nullish coalescing +const value = config.value ?? defaultValue; +``` + +## Affected Functionality + +This bug affected: +- Event cooldown: 0 settings (immediate events) +- Any numeric config that could legitimately be 0 + +## Testing + +**Before fix:** +``` +cooldown: 0 in JSON โ Event fires with 5000ms delay โ +``` + +**After fix:** +``` +cooldown: 0 in JSON โ Event fires immediately โ +``` + +To test: +1. Set `"cooldown": 0` in eventMappings +2. Trigger the event multiple times rapidly +3. Should fire every time (no cooldown) + +## Related Code Locations + +- **Bug location:** `js/systems/npc-manager.js:359` +- **Usage:** Event mapping cooldown handling +- **Similar patterns:** Check for other `||` uses with numeric values + +## Lesson Learned + +When providing numeric configuration values in JSON, always use explicit null/undefined checks rather than truthy coercion operators (`||`). Consider using modern JavaScript nullish coalescing (`??`) operator instead. + +--- + +**Fixed:** 2025-11-14 +**Commit:** Fix cooldown: 0 bug - explicit null/undefined check diff --git a/planning_notes/npc/hostile/implementation/EVENT_FLOW_COMPLETE.md b/planning_notes/npc/hostile/implementation/EVENT_FLOW_COMPLETE.md new file mode 100644 index 0000000..d5d8b8f --- /dev/null +++ b/planning_notes/npc/hostile/implementation/EVENT_FLOW_COMPLETE.md @@ -0,0 +1,314 @@ +# Complete Event-Triggered Conversation Flow + +## Overview + +This document traces the complete flow of how an event-triggered conversation now works after the recent fixes. + +## Architecture + +``` +Event Triggered (lockpick_used_in_view) + โ +EventDispatcher emits event + โ +NPCManager._handleEventMapping() catches event + โ + [Line of Sight Check] + NPC can see player? โ Event continues + โ + [Event Cooldown Check - FIXED: cooldown: 0 now works] + โ Event not on cooldown? โ Event continues + โ + [Conversation Mode Check] + Is person-chat? โ Yes + โ + [Check for Active Conversation] + Is same NPC already in conversation? โ Jump to knot (future enhancement) + Otherwise โ Start new conversation with startKnot + โ +MinigameFramework.startMinigame('person-chat', null, { + npcId: 'security_guard', + startKnot: 'on_lockpick_used', โ EVENT RESPONSE KNOT + scenario: window.gameScenario +}) + โ +PersonChatMinigame constructor: + this.startKnot = params.startKnot = 'on_lockpick_used' โ STORED + โ +PersonChatMinigame.start() โ PersonChatMinigame.startConversation() + โ + [Load Ink Story] + โ Story loaded + โ + [Check if startKnot provided - NEW LOGIC] + this.startKnot === 'on_lockpick_used'? โ YES + โ + [Jump to Event Knot - SKIPS STATE RESTORATION] + this.conversation.goToKnot('on_lockpick_used') + โ + [Sync Global Variables] + โ Synced + โ +PersonChatMinigame.showCurrentDialogue() + โ + Display dialogue from 'on_lockpick_used' knot + โ Event response appears immediately +``` + +## Code Flow + +### 1. Event Triggering (unlock-system.js) + +```javascript +// Player uses lockpick near NPC who can see them +// Event is dispatched with event data +window.eventDispatcher?.emit('lockpick_used_in_view', { + npcId: 'security_guard', + roomId: 'patrol_corridor', + lockable: initialize, + timestamp: 1763129060011 +}); +``` + +### 2. Event Caught by NPCManager (npc-manager.js:330) + +```javascript +_handleEventMapping(npcId, eventPattern, config, eventData) { + // Console: ๐ฏ Event triggered: lockpick_used_in_view for NPC: security_guard + + // ... validation checks ... + + // Line 359: FIX - Cooldown handling with explicit null/undefined check + const cooldown = config.cooldown !== undefined && config.cooldown !== null + ? config.cooldown + : 5000; + // If cooldown: 0, this now correctly evaluates to 0 (not 5000) + + // Check last trigger time + const now = Date.now(); + const lastTime = this.triggeredEvents.get(eventKey)?.lastTime || 0; + if (now - lastTime < cooldown) { + console.log(`โธ๏ธ Event on cooldown`); + return; // Skip - still on cooldown + } + + // Cooldown check passed โ + + // Update last trigger time + this.triggeredEvents.set(eventKey, { + count: (this.triggeredEvents.get(eventKey)?.count || 0) + 1, + lastTime: now + }); + + // Continue to conversation mode handling +} +``` + +### 3. Person-Chat Mode Handler (npc-manager.js:410) + +```javascript +if (config.conversationMode === 'person-chat' && npc.npcType === 'person') { + // console.log: ๐ค Handling person-chat for event on NPC security_guard + + // Check for active conversation + const currentConvNPCId = window.currentConversationNPCId; // null if no conversation + const activeMinigame = window.MinigameFramework?.currentMinigame; + const isPersonChatActive = activeMinigame?.constructor?.name === 'PersonChatMinigame'; + + // For new conversations: isConversationActive will be false + // So we skip the jump logic and go straight to starting new conversation + + // console.log: ๐ค Starting new person-chat conversation for NPC security_guard + + // Close any currently running minigame (like lockpicking) + if (window.MinigameFramework?.currentMinigame) { + window.MinigameFramework.endMinigame(false, null); + } + + // Start minigame WITH startKnot parameter โ KEY CHANGE + window.MinigameFramework.startMinigame('person-chat', null, { + npcId: npc.id, // 'security_guard' + startKnot: config.knot || npc.currentKnot, // 'on_lockpick_used' + scenario: window.gameScenario + }); +} +``` + +### 4. MinigameFramework Starts PersonChatMinigame + +```javascript +// minigame-manager.js +startMinigame('person-chat', null, { + npcId: 'security_guard', + startKnot: 'on_lockpick_used', + scenario: window.gameScenario +}); + +// Creates PersonChatMinigame instance +// params = { npcId, startKnot, scenario } +``` + +### 5. PersonChatMinigame Constructor (FIXED) + +```javascript +constructor(container, params) { + // ... setup ... + + this.npcId = params.npcId; // 'security_guard' + this.startKnot = params.startKnot; // 'on_lockpick_used' โ STORED + + // console.log: ๐ญ PersonChatMinigame created for NPC: security_guard +} +``` + +### 6. PersonChatMinigame.start() + +```javascript +start() { + super.start(); + // console.log: ๐ญ PersonChatMinigame started + + window.currentConversationNPCId = this.npcId; // 'security_guard' + window.currentConversationMinigameType = 'person-chat'; + + this.startConversation(); +} +``` + +### 7. startConversation() - NEW LOGIC (FIXED) + +```javascript +async startConversation() { + // Load Ink story + this.conversation = new PhoneChatConversation(this.npcId, ...); + const loaded = await this.conversation.loadStory(this.npc.storyPath); + + if (!loaded) return; + + // โก NEW: Check if startKnot was provided (event-triggered) + if (this.startKnot) { // 'on_lockpick_used' + console.log(`โก Event-triggered conversation: jumping directly to knot: on_lockpick_used`); + + // Jump to event knot - SKIP STATE RESTORATION + this.conversation.goToKnot(this.startKnot); + + // console.log: โก Event-triggered conversation: jumping directly to knot: on_lockpick_used + } else { + // Original logic: restore previous state if exists + const stateRestored = npcConversationStateManager.restoreNPCState( + this.npcId, + this.inkEngine.story + ); + // ... + } + + // Always sync global variables + npcConversationStateManager.syncGlobalVariablesToStory(this.inkEngine.story); + + // Show initial dialogue + this.showCurrentDialogue(); // Displays 'on_lockpick_used' knot content + + console.log('โ Conversation started'); +} +``` + +### 8. Display Event Response + +```javascript +showCurrentDialogue() { + // Get current story content from 'on_lockpick_used' knot + const result = this.inkEngine.continue(); + + // Result contains dialogue text and choices from the event response knot + // Display it in the UI + this.ui.showDialogue(result); +} +``` + +## Expected Console Output + +When lockpicking event triggers with security_guard in line of sight: + +``` +npc-manager.js:206 ๐ซ INTERRUPTING LOCKPICKING: NPC "security_guard" can see player and has person-chat mapped to lockpick event +unlock-system.js:122 ๐ซ LOCKPICKING INTERRUPTED: Triggering person-chat with NPC "security_guard" +npc-manager.js:330 ๐ฏ Event triggered: lockpick_used_in_view for NPC: security_guard +npc-manager.js:387 โ Event lockpick_used_in_view conditions passed, triggering NPC reaction +npc-manager.js:397 ๐ Updated security_guard current knot to: on_lockpick_used +npc-manager.js:411 ๐ค Handling person-chat for event on NPC security_guard +npc-manager.js:419 ๐ Event jump check: {..., isConversationActive: false, ...} +npc-manager.js:452 ๐ค Starting new person-chat conversation for NPC security_guard +minigame-manager.js:30 ๐ฎ Starting minigame: person-chat +person-chat-minigame.js:83 ๐ญ PersonChatMinigame created for NPC: security_guard +person-chat-minigame.js:282 ๐ญ PersonChatMinigame started +person-chat-minigame.js:298 โก Event-triggered conversation: jumping directly to knot: on_lockpick_used +person-chat-ui.js:80 โ PersonChatUI rendered +person-chat-minigame.js:179 โ PersonChatMinigame initialized +person-chat-minigame.js:346 โ Conversation started +``` + +The key console line is: +``` +person-chat-minigame.js:298 โก Event-triggered conversation: jumping directly to knot: on_lockpick_used +``` + +This indicates the event response is being triggered correctly. + +## Test Scenario + +File: `scenarios/npc-patrol-lockpick.json` + +Both NPCs have: +```json +"eventMappings": [ + { + "eventPattern": "lockpick_used_in_view", + "targetKnot": "on_lockpick_used", + "conversationMode": "person-chat", + "cooldown": 0 + } +] +``` + +The `cooldown: 0` means events fire immediately with no delay between them. + +### Test Steps + +1. Load scenario from `scenario_select.html` +2. Select `npc-patrol-lockpick.json` +3. Navigate to `patrol_corridor` +4. Find the lock (lockpicking object) +5. Get the `security_guard` NPC in line of sight +6. Use lockpicking action +7. Observe: + - Lockpicking is interrupted immediately + - Person-chat window opens with event response dialogue + - Console shows `โก Event-triggered conversation: jumping directly to knot: on_lockpick_used` + +## Related Bug Fixes + +This fix builds on two previous fixes in the same session: + +1. **Cooldown: 0 Bug Fix** - JavaScript falsy value bug where `config.cooldown || 5000` treated 0 as falsy, defaulting to 5000ms + - Fixed: `const cooldown = config.cooldown !== undefined && config.cooldown !== null ? config.cooldown : 5000` + - File: `js/systems/npc-manager.js:359` + +2. **Event Start Knot Fix** - PersonChatMinigame was ignoring the `startKnot` parameter passed from NPCManager + - Fixed: Added `this.startKnot` parameter storage and state restoration bypass logic + - File: `js/minigames/person-chat/person-chat-minigame.js:53, 315-340` + +## Architecture Improvements + +The fixes establish a clear pattern for event-triggered conversations: + +1. **Event Detection** โ NPCManager validates and processes event +2. **Parameter Passing** โ Passes `startKnot` to minigame initialization +3. **Early Branching** โ PersonChatMinigame checks for `startKnot` early in `startConversation()` +4. **State Bypass** โ If `startKnot` is present, skip normal state restoration +5. **Direct Navigation** โ Jump immediately to target knot +6. **Display** โ Show content from target knot to player + +This pattern could be extended to: +- Jump-to-knot while already in conversation (change line 427 logic in npc-manager.js) +- Other conversation types (phone-chat, etc.) +- Timed conversations (time-based events) diff --git a/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT.md b/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT.md new file mode 100644 index 0000000..f21c3f3 --- /dev/null +++ b/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT.md @@ -0,0 +1,200 @@ +# Event Mapping: Jump to Knot in Active Conversation + +## Overview + +When a player is already engaged in a conversation with an NPC and an event occurs (like lockpicking detected in view), the system now **jumps to the target knot within the existing conversation** instead of starting a new conversation. + +This creates seamless, reactive dialogue where the NPC can react to events without interrupting or restarting the conversation. + +## Implementation + +### Changes Made + +#### 1. PersonChatMinigame (`js/minigames/person-chat/person-chat-minigame.js`) + +Added new `jumpToKnot()` method that allows jumping to any knot while a conversation is active: + +```javascript +jumpToKnot(knotName) { + if (!knotName) { + console.warn('jumpToKnot: No knot name provided'); + return false; + } + + if (!this.inkEngine || !this.inkEngine.story) { + console.warn('jumpToKnot: Ink engine not initialized'); + return false; + } + + try { + console.log(`๐ฏ PersonChatMinigame.jumpToKnot() - Jumping to: ${knotName}`); + + // Jump to the knot + this.inkEngine.goToKnot(knotName); + + // Clear any pending callbacks since we're changing the story + if (this.autoAdvanceTimer) { + clearTimeout(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + } + this.pendingContinueCallback = null; + + // Show the new dialogue at the target knot + this.showCurrentDialogue(); + + console.log(`โ Successfully jumped to knot: ${knotName}`); + return true; + } catch (error) { + console.error(`โ Error jumping to knot ${knotName}:`, error); + return false; + } +} +``` + +**What it does:** +- Takes a knot name as parameter +- Uses the existing `InkEngine.goToKnot()` to navigate to that knot +- Clears any pending timers/callbacks +- Displays the dialogue at the new knot +- Returns success/failure status + +#### 2. NPCManager (`js/systems/npc-manager.js`) + +Updated `_handleEventMapping()` to detect active conversations and jump instead of starting new ones: + +```javascript +// CHECK: Is a conversation already active with this NPC? +const isConversationActive = window.currentConversationNPCId === npcId; +const activeMinigame = window.MinigameFramework?.currentMinigame; +const isPersonChatActive = activeMinigame?.constructor?.name === 'PersonChatMinigame'; + +if (isConversationActive && isPersonChatActive) { + // JUMP TO KNOT in the active conversation instead of starting a new one + console.log(`โก Active conversation detected with ${npcId}, jumping to knot: ${config.knot}`); + + if (typeof activeMinigame.jumpToKnot === 'function') { + const jumpSuccess = activeMinigame.jumpToKnot(config.knot); + if (jumpSuccess) { + console.log(`โ Successfully jumped to knot ${config.knot} in active conversation`); + return; // Success - exit early + } else { + console.warn(`โ ๏ธ Failed to jump to knot, falling back to new conversation`); + } + } else { + console.warn(`โ ๏ธ jumpToKnot method not available on minigame`); + } +} + +// Not in an active conversation OR jump failed - start a new person-chat minigame +console.log(`๐ค Starting new person-chat conversation for NPC ${npcId}`); +// ... start new conversation as before +``` + +**Decision flow:** +1. Check if `window.currentConversationNPCId` matches the NPC that triggered the event +2. Check if the current minigame is `PersonChatMinigame` +3. If both true โ Call `jumpToKnot()` and exit +4. If jump fails or conditions not met โ Start a new conversation (fallback) + +## Usage Example + +Scenario: Security guard is talking to player, then player starts lockpicking + +### Ink File (security-guard.ink) + +```ink +=== on_lockpick_used === +# speaker:security_guard +Hey! What do you think you're doing with that lock? + +* [I was just... looking for something I dropped] + -> explain_drop +* [Mind your own business] + -> hostile_response +``` + +### Scenario JSON + +```json +{ + "eventMappings": [ + { + "eventPattern": "lockpick_used_in_view", + "targetKnot": "on_lockpick_used", + "conversationMode": "person-chat", + "cooldown": 0 + } + ] +} +``` + +### Behavior + +**Scenario A: Player already in conversation** +1. Player is in conversation with security guard (could be at "hub" or any dialogue) +2. Player uses lockpick โ `lockpick_used_in_view` event fires +3. NPCManager detects active conversation with this NPC +4. Calls `jumpToKnot('on_lockpick_used')` +5. Conversation seamlessly switches to the lockpick response +6. Player can continue dialogue from there + +**Scenario B: Player not in conversation** +1. Player is in game world, not talking to security guard +2. Player uses lockpick โ `lockpick_used_in_view` event fires +3. NPCManager detects no active conversation +4. Starts new person-chat conversation with `startKnot: 'on_lockpick_used'` +5. Conversation opens with the lockpick response + +## Benefits + +โ **Seamless reactions** - NPCs react to events without interrupting dialogue flow +โ **Player context preserved** - If player was in middle of dialogue, they continue after the reaction +โ **Graceful fallback** - If jump fails, system falls back to starting new conversation +โ **Reusable knots** - Same `on_lockpick_used` knot works whether starting new conversation or jumping mid-conversation + +## Console Output + +When working correctly, you'll see in the console: + +``` +โก Active conversation detected with security_guard, jumping to knot: on_lockpick_used +๐ฏ PersonChatMinigame.jumpToKnot() - Jumping to: on_lockpick_used +๐ฃ๏ธ showCurrentDialogue - result.text: "Hey! What do you think you're doing..." (58 chars) +โ Successfully jumped to knot: on_lockpick_used +``` + +## Testing + +### Test Case 1: Jump While in Conversation + +1. Start conversation with security guard (scenario_select.html) +2. Navigate to some dialogue option +3. While still in conversation, trigger lockpick event +4. Expect: Conversation jumps to `on_lockpick_used` knot + +### Test Case 2: Start New Conversation with Event Knot + +1. In game world, NOT in conversation with security guard +2. Use lockpicking nearby security guard +3. Expect: New conversation starts directly at `on_lockpick_used` knot + +### Test Case 3: Fallback to New Conversation + +1. Start conversation with Security Guard +2. Manually create scenario where `jumpToKnot` would fail (or remove method) +3. Trigger lockpick event +4. Expect: System detects jump failure and falls back to starting new conversation + +## Related Files + +- `js/minigames/person-chat/person-chat-minigame.js` - `jumpToKnot()` implementation +- `js/systems/npc-manager.js` - Event mapping handler with jump logic +- `js/systems/ink/ink-engine.js` - `goToKnot()` method (called by jumpToKnot) +- `scenarios/npc-patrol-lockpick.json` - Example scenario with eventMappings + +## Future Enhancements + +- [ ] Add transition animations when jumping to knots +- [ ] Track which knots were jumped to vs. naturally reached (for analytics) +- [ ] Add option to dismiss event reactions and continue current dialogue +- [ ] Support nested knot jumps (jumping within a jumped knot) diff --git a/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT_QUICK_REF.md b/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT_QUICK_REF.md new file mode 100644 index 0000000..91b7d2b --- /dev/null +++ b/planning_notes/npc/hostile/implementation/EVENT_JUMP_TO_KNOT_QUICK_REF.md @@ -0,0 +1,182 @@ +# Event Jump to Knot - Quick Reference + +## What's New? + +When an event fires during an active conversation with an NPC, the conversation **jumps to the target knot** instead of starting a new conversation. + +## Key Concepts + +### 1. Active Conversation Detection + +The system checks: +```javascript +window.currentConversationNPCId === npcId // Is this the NPC in the conversation? +activeMinigame?.constructor?.name === 'PersonChatMinigame' // Is it a person-chat? +``` + +### 2. Jump vs. Start Decision + +| Scenario | Action | +|----------|--------| +| In conversation with NPC X, event triggered for X | โก **Jump** to targetKnot | +| Not in conversation, event triggered for X | ๐ **Start** new conversation | +| In conversation with NPC Y, event triggered for X | ๐ **Start** new conversation (close Y's first) | + +### 3. How Jumping Works + +``` +Current Dialogue State: + NPC: "What do you want?" + Ink Position: =hub=== + +Event Fires: + lockpick_used_in_view โ targetKnot: on_lockpick_used + +Jump Happens: + InkEngine.goToKnot("on_lockpick_used") + Clear pending timers + Show current dialogue at new knot + +New Dialogue State: + NPC: "Hey! What are you doing with that lock?" + Ink Position: =on_lockpick_used=== +``` + +## Implementation Details + +### PersonChatMinigame.jumpToKnot() + +**Location:** `js/minigames/person-chat/person-chat-minigame.js:880` + +**Signature:** +```javascript +jumpToKnot(knotName: string): boolean +``` + +**Returns:** `true` on success, `false` on failure + +**Does:** +1. Validates knot name and ink engine exist +2. Calls `this.inkEngine.goToKnot(knotName)` +3. Clears auto-advance timer +4. Clears pending callbacks +5. Shows dialogue at new knot +6. Logs status + +### NPCManager._handleEventMapping() + +**Location:** `js/systems/npc-manager.js:412` + +**Change:** Added conversation detection before starting new person-chat + +**Logic:** +```javascript +if (config.conversationMode === 'person-chat' && npc.npcType === 'person') { + // Check if already talking to this NPC + if (isConversationActive && isPersonChatActive) { + // Jump instead of starting new + if (activeMinigame.jumpToKnot(config.knot)) { + return; // Success! + } + } + + // Fallback: Start new conversation + window.MinigameFramework.startMinigame('person-chat', null, { + npcId: npc.id, + startKnot: config.knot, + scenario: window.gameScenario + }); +} +``` + +## Usage in Scenarios + +### JSON Format (Already Supported) + +```json +{ + "id": "security_guard", + "eventMappings": [ + { + "eventPattern": "lockpick_used_in_view", + "targetKnot": "on_lockpick_used", + "conversationMode": "person-chat", + "cooldown": 0 + } + ] +} +``` + +### Ink Format (Already Supported) + +```ink +=== on_lockpick_used === +# speaker:security_guard +Hey! What are you doing? + +* [Oops, sorry] + -> apologize +* [Mind your business] + -> hostile_response +``` + +## Debugging + +### Enable Debug Logging + +In console: +```javascript +window.npcManager.debug = true; +``` + +Then trigger an event and watch console: + +``` +๐ฏ Event triggered: lockpick_used_in_view for NPC: security_guard +โ Event conditions passed, triggering NPC reaction +๐ค Handling person-chat for event on NPC security_guard +โก Active conversation detected with security_guard, jumping to knot: on_lockpick_used +๐ฏ PersonChatMinigame.jumpToKnot() - Jumping to: on_lockpick_used +โ Successfully jumped to knot: on_lockpick_used +``` + +### Common Issues + +**Issue:** Jump not happening, new conversation started instead +- Check: `window.currentConversationNPCId` - Should equal the NPC ID +- Check: Active minigame type - Should be `PersonChatMinigame` +- Check: Event mapping has `"conversationMode": "person-chat"` + +**Issue:** Dialogue shows old content after jump +- Check: Browser cache - Hard refresh (Ctrl+Shift+R) +- Check: Ink JSON compiled - Recompile `.ink` file: `inklecate -ojv story.json story.ink` + +**Issue:** Jump method not found error +- Check: PersonChatMinigame loaded - Should be in `js/minigames/person-chat/` +- Check: Method exists at line 880 + +## Files Modified + +- โ `js/minigames/person-chat/person-chat-minigame.js` - Added `jumpToKnot()` method +- โ `js/systems/npc-manager.js` - Updated `_handleEventMapping()` for detection +- โ `docs/EVENT_JUMP_TO_KNOT.md` - Full documentation (new) +- โ `docs/EVENT_JUMP_TO_KNOT_QUICK_REF.md` - This file (new) + +## Testing Checklist + +- [ ] Start conversation with NPC +- [ ] Trigger event while in conversation +- [ ] Verify dialogue jumps to targetKnot +- [ ] Make choices in target knot +- [ ] Verify conversation continues normally +- [ ] Test with multiple events +- [ ] Test without conversation active (should start new) +- [ ] Test switching between NPCs + +--- + +**Status:** โ Implemented and ready to use + +**Added:** 2025-11-14 + +**Related:** `npc-patrol-lockpick.json` scenario test diff --git a/planning_notes/npc/hostile/implementation/EVENT_START_KNOT_FIX.md b/planning_notes/npc/hostile/implementation/EVENT_START_KNOT_FIX.md new file mode 100644 index 0000000..f7eda8c --- /dev/null +++ b/planning_notes/npc/hostile/implementation/EVENT_START_KNOT_FIX.md @@ -0,0 +1,132 @@ +# Event-Triggered Start Knot Fix + +## Problem + +When an event-triggered conversation was started (via `NPCManager._handleEventMapping()`), the PersonChatMinigame would ignore the `startKnot` parameter that was passed. Instead, it would: + +1. Check if a previous conversation state existed in `npcConversationStateManager` +2. If found, restore to that previous state instead of jumping to the event knot +3. If not found, start from the default `start` knot + +This meant that event responses (like `on_lockpick_used`) would never be displayed - the conversation would either restore to an old state or start from the beginning. + +**Root Cause:** The `PersonChatMinigame.startConversation()` method had no logic to check for or use the `startKnot` parameter that was being passed from `NPCManager`. + +## Solution + +### Change 1: Store startKnot in Constructor (Line 53) + +```javascript +this.startKnot = params.startKnot; // Optional knot to jump to (used for event-triggered conversations) +``` + +Store the `startKnot` parameter passed from `NPCManager` as an instance variable for later use. + +### Change 2: Skip State Restoration When startKnot Provided (Lines 315-340) + +**Before:** +```javascript +// Restore previous conversation state if it exists +const stateRestored = npcConversationStateManager.restoreNPCState( + this.npcId, + this.inkEngine.story +); + +if (stateRestored) { + this.conversation.storyEnded = false; + console.log(`๐ Continuing previous conversation with ${this.npcId}`); +} else { + const startKnot = this.npc.currentKnot || 'start'; + this.conversation.goToKnot(startKnot); + console.log(`๐ Starting new conversation with ${this.npcId}`); +} +``` + +**After:** +```javascript +// If a startKnot was provided (event-triggered conversation), jump directly to it +// This skips state restoration and goes straight to the event response +if (this.startKnot) { + console.log(`โก Event-triggered conversation: jumping directly to knot: ${this.startKnot}`); + this.conversation.goToKnot(this.startKnot); +} else { + // Otherwise, restore previous conversation state if it exists + const stateRestored = npcConversationStateManager.restoreNPCState( + this.npcId, + this.inkEngine.story + ); + + if (stateRestored) { + this.conversation.storyEnded = false; + console.log(`๐ Continuing previous conversation with ${this.npcId}`); + } else { + const startKnot = this.npc.currentKnot || 'start'; + this.conversation.goToKnot(startKnot); + console.log(`๐ Starting new conversation with ${this.npcId}`); + } +} +``` + +**Logic:** +1. Check if `this.startKnot` was provided (set by NPCManager for event-triggered conversations) +2. If yes: **Jump directly to that knot** - bypassing state restoration entirely +3. If no: **Use existing logic** - restore state if available, otherwise start from default + +## Impact + +### For Event-Triggered Conversations + +When `NPCManager._handleEventMapping()` detects a lockpick event with `config.knot = 'on_lockpick_used'`: + +1. It calls: `window.MinigameFramework.startMinigame('person-chat', null, { npcId, startKnot: 'on_lockpick_used', ... })` +2. PersonChatMinigame constructor receives this and stores: `this.startKnot = 'on_lockpick_used'` +3. When `startConversation()` runs, it sees `this.startKnot` and **immediately jumps to that knot** +4. Player sees the event response dialogue (e.g., "Hey! What do you think you're doing with that lock?") + +### For Normal Conversations + +When a player starts a normal conversation (no event): + +1. `startKnot` is undefined +2. Code falls through to the original logic +3. State is restored if available (for conversation continuation) +4. Otherwise starts from the default knot + +## Console Output Example + +**Event-triggered jump:** +``` +โก Event-triggered conversation: jumping directly to knot: on_lockpick_used +``` + +**Normal conversation (with existing state):** +``` +๐ Continuing previous conversation with security_guard +``` + +**Normal conversation (first time):** +``` +๐ Starting new conversation with security_guard +``` + +## Files Modified + +- `js/minigames/person-chat/person-chat-minigame.js` + - Line 53: Added `this.startKnot = params.startKnot` + - Lines 315-340: Restructured state restoration logic with startKnot check + +## Testing Checklist + +- [ ] Start conversation with NPC (should restore previous state if exists) +- [ ] Trigger an event while NOT in conversation (should start new conversation with event knot) +- [ ] Trigger an event while in conversation with SAME NPC (should close and start with event knot) +- [ ] Trigger an event while in conversation with DIFFERENT NPC (should close first and start with event knot) +- [ ] Verify console shows `โก Event-triggered conversation` for event-triggered starts +- [ ] Verify event response dialogue appears immediately + +## Related Files + +- `js/systems/npc-manager.js` - Passes `startKnot` when starting minigame (line 465) +- `scenarios/npc-patrol-lockpick.json` - Test scenario with event mappings +- `js/systems/ink/ink-engine.js` - `goToKnot()` method +- `js/minigames/phone-chat/phone-chat-conversation.js` - `goToKnot()` method diff --git a/planning_notes/npc/hostile/implementation/EVENT_TRIGGERED_QUICK_REF.md b/planning_notes/npc/hostile/implementation/EVENT_TRIGGERED_QUICK_REF.md new file mode 100644 index 0000000..41590c1 --- /dev/null +++ b/planning_notes/npc/hostile/implementation/EVENT_TRIGGERED_QUICK_REF.md @@ -0,0 +1,148 @@ +# Event-Triggered Conversation - Quick Reference + +## Problem โ Solution + +| Problem | Root Cause | Solution | File | Line | +|---------|-----------|----------|------|------| +| Cooldown: 0 treated as falsy | `0 \|\| 5000` โ 5000 | Explicit null/undefined check | npc-manager.js | 359 | +| Event response knot ignored | PersonChatMinigame didn't check startKnot param | Store startKnot and use it before state restoration | person-chat-minigame.js | 53, 315-340 | + +## What Was Fixed + +### Fix 1: Cooldown Default (npc-manager.js:359) + +**Before:** +```javascript +const cooldown = config.cooldown || 5000; // 0 becomes 5000 โ +``` + +**After:** +```javascript +const cooldown = config.cooldown !== undefined && config.cooldown !== null + ? config.cooldown + : 5000; // 0 becomes 0 โ +``` + +### Fix 2: Event Start Knot (person-chat-minigame.js) + +**Constructor (line 53):** +```javascript +this.startKnot = params.startKnot; // Store for later +``` + +**startConversation() (lines 315-340):** +```javascript +if (this.startKnot) { + // Jump directly to event knot, skip state restoration + this.conversation.goToKnot(this.startKnot); +} else { + // Normal flow: restore previous or start from beginning + // ... existing logic ... +} +``` + +## Flow Diagram + +``` +Event: lockpick_used_in_view + โ +NPCManager: Validate cooldown โ (cooldown: 0 now works) + โ +NPCManager: Start person-chat with startKnot: 'on_lockpick_used' + โ +PersonChatMinigame: Store this.startKnot = 'on_lockpick_used' + โ +PersonChatMinigame.startConversation(): + - Check: this.startKnot exists? YES + - Jump to knot (skip state restoration) + โ +Show event response dialogue โ +``` + +## Console Log Indicators + +**โ Event working correctly:** +``` +npc-manager.js:330 ๐ฏ Event triggered: lockpick_used_in_view for NPC: security_guard +npc-manager.js:387 โ Event lockpick_used_in_view conditions passed +npc-manager.js:411 ๐ค Handling person-chat for event on NPC security_guard +person-chat-minigame.js:298 โก Event-triggered conversation: jumping directly to knot: on_lockpick_used +``` + +**โ Event blocked by cooldown (OLD BUG):** +``` +npc-manager.js:330 ๐ฏ Event triggered: lockpick_used_in_view for NPC: security_guard +npc-manager.js:??? โธ๏ธ Event lockpick_used_in_view on cooldown (5000ms remaining) +``` + +**โ Event ignored by minigame (OLD BUG):** +``` +person-chat-minigame.js:X ๐ Continuing previous conversation with security_guard +``` +(Should see: `โก Event-triggered conversation` instead) + +## Testing + +### Quick Test +1. Open scenario: `npc-patrol-lockpick.json` +2. Navigate to `patrol_corridor` +3. Use lockpicking action +4. NPC should immediately respond with event dialogue +5. Check console for: `โก Event-triggered conversation` + +### Expected Behavior + +**Before Fixes:** +- Lockpicking event triggered โ Console shows on cooldown OR ignores event knot +- Person-chat opens but shows old conversation state, not event response + +**After Fixes:** +- Lockpicking event triggered โ Immediately interrupts lockpicking +- Person-chat opens showing event response dialogue ("Hey! What do you think you're doing with that lock?") +- Console shows: `โก Event-triggered conversation: jumping directly to knot: on_lockpick_used` + +## Files Modified + +1. `js/systems/npc-manager.js` - Line 359 +2. `js/minigames/person-chat/person-chat-minigame.js` - Lines 53, 315-340 + +## Documentation + +- `docs/EVENT_START_KNOT_FIX.md` - Detailed explanation of Fix 2 +- `docs/EVENT_FLOW_COMPLETE.md` - Complete flow diagram with all code paths +- `docs/COOLDOWN_ZERO_BUG_FIX.md` - Detailed explanation of Fix 1 + +## Key Insight + +**State restoration was blocking event responses.** + +The system was designed to restore previous conversation state (for conversation continuation), but this happened BEFORE checking if an event-triggered start knot was provided. By checking for `startKnot` FIRST, we ensure event responses take precedence over state restoration. + +## Next Steps (Future Enhancement) + +The current implementation starts a new conversation when an event fires. A future enhancement could: + +1. While in conversation with NPC A, lockpick event happens with NPC A in view +2. Instead of starting new conversation, **jump to event knot within the current conversation** +3. Code location: `js/systems/npc-manager.js` lines 427-428 + +Current code: +```javascript +if (isConversationActive && isPersonChatActive) { + // Jump logic (partially implemented) +} else { + // Start new conversation (current behavior) +} +``` + +To enable same-NPC jumps, modify line 427 condition from: +```javascript +if (isConversationActive && isPersonChatActive) // Only jumps if same NPC +``` + +To: +```javascript +if (isPersonChatActive) // Jump for any active person-chat +``` + +But current behavior (closing and starting new) is safe and prevents state confusion. diff --git a/planning_notes/npc/hostile/implementation/HEALTH_UI_FIX.md b/planning_notes/npc/hostile/implementation/HEALTH_UI_FIX.md new file mode 100644 index 0000000..581017a --- /dev/null +++ b/planning_notes/npc/hostile/implementation/HEALTH_UI_FIX.md @@ -0,0 +1,184 @@ +# Health UI Display Fix + +## Problem +The health UI was not displaying when the player took damage. The HUD needs to show above the inventory with proper z-index layering. + +## Solution + +### Changes Made + +#### 1. Updated `js/ui/health-ui.js` +- **Changed from emoji hearts to PNG image icons** + - Full heart: `assets/icons/heart.png` + - Half heart: `assets/icons/heart-half.png` + - Empty heart: `assets/icons/heart.png` with 20% opacity + +- **Updated HTML structure** + - Changed from `