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/config/combat-config.js b/js/config/combat-config.js new file mode 100644 index 0000000..6ca41fd --- /dev/null +++ b/js/config/combat-config.js @@ -0,0 +1,38 @@ +export const COMBAT_CONFIG = { + player: { + maxHP: 100, + punchDamage: 20, + punchRange: 60, + punchCooldown: 1000, + punchAnimationDuration: 500 + }, + npc: { + defaultMaxHP: 100, + defaultPunchDamage: 10, + defaultPunchRange: 50, + defaultAttackCooldown: 2000, + attackWindupDuration: 500, + chaseSpeed: 120, + chaseRange: 400, + attackStopDistance: 45 + }, + ui: { + maxHearts: 5, + healthBarWidth: 60, + healthBarHeight: 6, + healthBarOffsetY: -40, + damageNumberDuration: 1000, + damageNumberRise: 50 + }, + feedback: { + enableScreenFlash: true, + enableScreenShake: true, + enableDamageNumbers: true, + enableSounds: true + }, + + validate() { + console.log('â Combat config loaded'); + return true; + } +}; diff --git a/js/core/game.js b/js/core/game.js index 11acea1..31a05d7 100644 --- a/js/core/game.js +++ b/js/core/game.js @@ -6,6 +6,19 @@ import { checkObjectInteractions, setGameInstance } from '../systems/interaction import { introduceScenario } from '../utils/helpers.js?v=19'; import '../minigames/index.js?v=2'; import SoundManager from '../systems/sound-manager.js?v=1'; +import { initPlayerHealth } from '../systems/player-health.js'; +import { initNPCHostileSystem } from '../systems/npc-hostile.js'; +import { COMBAT_CONFIG } from '../config/combat-config.js'; +import { initCombatDebug } from '../utils/combat-debug.js'; +import { DamageNumbersSystem } from '../systems/damage-numbers.js'; +import { ScreenEffectsSystem } from '../systems/screen-effects.js'; +import { SpriteEffectsSystem } from '../systems/sprite-effects.js'; +import { AttackTelegraphSystem } from '../systems/attack-telegraph.js'; +import { HealthUI } from '../ui/health-ui.js'; +import { NPCHealthBars } from '../ui/npc-health-bars.js'; +import { GameOverScreen } from '../ui/game-over-screen.js'; +import { PlayerCombat } from '../systems/player-combat.js'; +import { NPCCombat } from '../systems/npc-combat.js'; // Global variables that will be set by main.js let gameScenario; @@ -562,6 +575,27 @@ export async function create() { }); } + // Initialize combat systems + COMBAT_CONFIG.validate(); + window.playerHealth = initPlayerHealth(); + window.npcHostileSystem = initNPCHostileSystem(); + window.playerCombat = new PlayerCombat(this); + window.npcCombat = new NPCCombat(this); + + // Initialize feedback systems + window.damageNumbers = new DamageNumbersSystem(this); + window.screenEffects = new ScreenEffectsSystem(this); + window.spriteEffects = new SpriteEffectsSystem(this); + window.attackTelegraph = new AttackTelegraphSystem(this); + + // Initialize UI systems + window.healthUI = new HealthUI(); + window.npcHealthBars = new NPCHealthBars(this); + window.gameOverScreen = new GameOverScreen(); + + initCombatDebug(); + console.log('â Combat systems ready'); + // Create only the starting room initially const roomPositions = calculateRoomPositions(this); const startingRoomData = gameScenario.rooms[gameScenario.startRoom]; @@ -756,7 +790,18 @@ export function update() { // Check for object interactions checkObjectInteractions.call(this); - + + // Update combat feedback systems + if (window.damageNumbers) { + window.damageNumbers.update(); + } + if (window.attackTelegraph) { + window.attackTelegraph.update(); + } + if (window.npcHealthBars) { + window.npcHealthBars.update(); + } + // Check for player bump effect when walking over floor items if (window.createPlayerBumpEffect) { window.createPlayerBumpEffect(); diff --git a/js/core/player.js b/js/core/player.js index 7f90f28..35d1875 100644 --- a/js/core/player.js +++ b/js/core/player.js @@ -412,7 +412,19 @@ export function updatePlayerMovement() { if (!player || !player.body) { return; } - + + // Check if player is KO (knocked out) - disable movement + if (window.playerHealth && window.playerHealth.isKO()) { + player.body.setVelocity(0, 0); + return; + } + + // Check if movement is explicitly disabled + if (player.disableMovement) { + player.body.setVelocity(0, 0); + return; + } + // Handle keyboard movement (takes priority over mouse movement) if (isKeyboardMoving) { updatePlayerKeyboardMovement(); diff --git a/js/events/combat-events.js b/js/events/combat-events.js new file mode 100644 index 0000000..22ed553 --- /dev/null +++ b/js/events/combat-events.js @@ -0,0 +1,7 @@ +export const CombatEvents = { + PLAYER_HP_CHANGED: 'player_hp_changed', + PLAYER_KO: 'player_ko', + NPC_HOSTILE_CHANGED: 'npc_hostile_state_changed', + NPC_BECAME_HOSTILE: 'npc_became_hostile', + NPC_KO: 'npc_ko' +}; diff --git a/js/minigames/helpers/chat-helpers.js b/js/minigames/helpers/chat-helpers.js index 434b8f7..e8cf005 100644 --- a/js/minigames/helpers/chat-helpers.js +++ b/js/minigames/helpers/chat-helpers.js @@ -208,7 +208,37 @@ export function processGameActionTags(tags, ui) { } } break; - + + case 'hostile': + { + const npcId = param || window.currentConversationNPCId; + + if (!npcId) { + result.message = 'â ī¸ hostile tag missing NPC ID'; + console.warn(result.message); + break; + } + + console.log(`đ´ Processing hostile tag for NPC: ${npcId}`); + + // Set NPC to hostile state + if (window.npcHostileSystem) { + window.npcHostileSystem.setNPCHostile(npcId, true); + result.success = true; + result.message = `â ī¸ ${npcId} is now hostile!`; + if (ui) ui.showNotification(result.message, 'warning'); + } else { + result.message = 'â ī¸ Hostile system not initialized'; + console.warn(result.message); + } + + // Emit event for other systems + if (window.eventDispatcher) { + window.eventDispatcher.emit('npc_became_hostile', { npcId }); + } + } + break; + default: // Unknown tag, log but don't fail console.log(`âšī¸ Unknown game action tag: ${action}`); 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/attack-telegraph.js b/js/systems/attack-telegraph.js new file mode 100644 index 0000000..10eec1f --- /dev/null +++ b/js/systems/attack-telegraph.js @@ -0,0 +1,158 @@ +/** + * Attack Telegraph System + * Visual indicators for incoming attacks to give players fair warning + */ + +export class AttackTelegraphSystem { + constructor(scene) { + this.scene = scene; + this.activeTelegraphs = new Map(); + + console.log('â Attack telegraph system initialized'); + } + + /** + * Show telegraph indicator for an NPC about to attack + * @param {string} npcId - NPC identifier + * @param {Phaser.GameObjects.Sprite} npcSprite - NPC sprite + * @param {number} duration - Telegraph duration in ms + */ + show(npcId, npcSprite, duration = 500) { + if (!npcSprite || !npcSprite.active) return; + + // Remove existing telegraph if any + this.hide(npcId); + + // Create visual indicator - exclamation mark above NPC + const indicator = this.scene.add.text( + npcSprite.x, + npcSprite.y - 50, + '!', + { + fontSize: '24px', + fontFamily: 'Arial', + fontStyle: 'bold', + color: '#ff0000', + stroke: '#000000', + strokeThickness: 3 + } + ); + indicator.setOrigin(0.5, 0.5); + indicator.setDepth(900); + + // Create danger zone circle around NPC + const dangerCircle = this.scene.add.circle( + npcSprite.x, + npcSprite.y, + 60, // Attack range radius + 0xff0000, + 0.15 + ); + dangerCircle.setStrokeStyle(2, 0xff0000, 0.5); + dangerCircle.setDepth(1); + + // Pulse animation for indicator + this.scene.tweens.add({ + targets: indicator, + scaleX: 1.3, + scaleY: 1.3, + duration: 250, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + + // Pulse animation for circle + this.scene.tweens.add({ + targets: dangerCircle, + scaleX: 1.1, + scaleY: 1.1, + alpha: 0.3, + duration: 250, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }); + + // Store references + this.activeTelegraphs.set(npcId, { + indicator, + dangerCircle, + npcSprite, + startTime: Date.now(), + duration + }); + + // Auto-hide after duration + this.scene.time.delayedCall(duration, () => { + this.hide(npcId); + }); + } + + /** + * Hide telegraph indicator + * @param {string} npcId + */ + hide(npcId) { + const telegraph = this.activeTelegraphs.get(npcId); + if (!telegraph) return; + + // Destroy visual elements + if (telegraph.indicator) { + telegraph.indicator.destroy(); + } + if (telegraph.dangerCircle) { + telegraph.dangerCircle.destroy(); + } + + this.activeTelegraphs.delete(npcId); + } + + /** + * Update telegraph positions to follow NPCs + * Called from game update loop + */ + update() { + this.activeTelegraphs.forEach((telegraph, npcId) => { + if (!telegraph.npcSprite || !telegraph.npcSprite.active) { + // NPC sprite is gone, clean up + this.hide(npcId); + return; + } + + // Update positions to follow NPC + if (telegraph.indicator) { + telegraph.indicator.setPosition( + telegraph.npcSprite.x, + telegraph.npcSprite.y - 50 + ); + } + if (telegraph.dangerCircle) { + telegraph.dangerCircle.setPosition( + telegraph.npcSprite.x, + telegraph.npcSprite.y + ); + } + }); + } + + /** + * Check if NPC has active telegraph + * @param {string} npcId + * @returns {boolean} + */ + isActive(npcId) { + return this.activeTelegraphs.has(npcId); + } + + /** + * Clean up system + */ + destroy() { + // Hide all telegraphs + this.activeTelegraphs.forEach((_, npcId) => { + this.hide(npcId); + }); + this.activeTelegraphs.clear(); + } +} diff --git a/js/systems/damage-numbers.js b/js/systems/damage-numbers.js new file mode 100644 index 0000000..9750e8d --- /dev/null +++ b/js/systems/damage-numbers.js @@ -0,0 +1,107 @@ +/** + * Damage Numbers System + * Displays floating damage numbers above entities using object pooling + */ + +export class DamageNumbersSystem { + constructor(scene) { + this.scene = scene; + this.pool = []; + this.active = []; + this.poolSize = 20; + + // Pre-create pool of text objects + for (let i = 0; i < this.poolSize; i++) { + const text = scene.add.text(0, 0, '', { + fontSize: '20px', + fontFamily: 'Arial', + fontStyle: 'bold', + stroke: '#000000', + strokeThickness: 4 + }); + text.setVisible(false); + text.setDepth(1000); // Above everything + this.pool.push(text); + } + + console.log('â Damage numbers system initialized'); + } + + /** + * Show damage number at position + * @param {number} x - World x position + * @param {number} y - World y position + * @param {number} amount - Damage amount + * @param {string} type - 'damage' or 'heal' + */ + show(x, y, amount, type = 'damage') { + // Get object from pool + const text = this.pool.pop(); + if (!text) { + console.warn('Damage number pool exhausted'); + return; + } + + // Configure text + text.setText(`${Math.round(amount)}`); + text.setPosition(x, y); + text.setVisible(true); + + // Set color based on type + if (type === 'damage') { + text.setColor('#ff4444'); // Red for damage + } else if (type === 'heal') { + text.setColor('#44ff44'); // Green for heal + } + + // Add to active list + this.active.push({ + text, + startY: y, + startTime: Date.now(), + duration: 1000 + }); + } + + /** + * Update all active damage numbers + * Called from game update loop + */ + update() { + const now = Date.now(); + + for (let i = this.active.length - 1; i >= 0; i--) { + const item = this.active[i]; + const elapsed = now - item.startTime; + const progress = elapsed / item.duration; + + if (progress >= 1) { + // Animation complete - return to pool + item.text.setVisible(false); + this.pool.push(item.text); + this.active.splice(i, 1); + } else { + // Update position and opacity + const riseDistance = 50; + const newY = item.startY - (riseDistance * progress); + item.text.setY(newY); + + // Fade out + const alpha = 1 - progress; + item.text.setAlpha(alpha); + } + } + } + + /** + * Clean up system + */ + destroy() { + // Destroy all text objects + [...this.pool, ...this.active.map(a => a.text)].forEach(text => { + if (text) text.destroy(); + }); + this.pool = []; + this.active = []; + } +} diff --git a/js/systems/interactions.js b/js/systems/interactions.js index 10fb6ba..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; @@ -453,35 +469,13 @@ export function handleObjectInteraction(sprite) { }); } - // Handle swivel chair interaction - send it flying! + // Handle swivel chair interaction - trigger punch to kick it! if (sprite.isSwivelChair && sprite.body) { const player = window.player; - if (player) { - // Calculate direction from player to chair - const dx = sprite.x - player.x; - const dy = sprite.y - player.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance > 0) { - // Normalize the direction vector - const dirX = dx / distance; - const dirY = dy / distance; - - // Apply a strong kick velocity - const kickForce = 1200; // Pixels per second - sprite.body.setVelocity(dirX * kickForce, dirY * kickForce); - - // Trigger spin direction calculation for visual rotation - if (window.calculateChairSpinDirection) { - window.calculateChairSpinDirection(player, sprite); - } - - // Show feedback message - console.log('SWIVEL CHAIR KICKED', { - chairName: sprite.name, - velocity: { x: dirX * kickForce, y: dirY * kickForce } - }); - } + if (player && window.playerCombat) { + // Trigger punch instead of directly kicking the chair + // The punch system will detect the chair and apply kick velocity + window.playerCombat.punch(); } return; } @@ -972,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); @@ -996,10 +993,21 @@ export function tryInteractWithNPC(npcSprite) { // Only interact if within range if (distance <= INTERACTION_RANGE) { + // Check if NPC is hostile - if so, trigger punch instead of conversation + const npcId = npcSprite.npcId; + if (npcId && window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(npcId)) { + // Hostile NPC - punch instead of talk + if (window.playerCombat) { + window.playerCombat.punch(); + } + return true; + } + + // Normal NPC interaction (conversation) handleObjectInteraction(npcSprite); return true; // Interaction successful } - + // Out of range - caller should handle movement return false; } diff --git a/js/systems/npc-behavior.js b/js/systems/npc-behavior.js index bb7a185..b76c344 100644 --- a/js/systems/npc-behavior.js +++ b/js/systems/npc-behavior.js @@ -473,16 +473,18 @@ class NPCBehavior { const dy = playerPos.y - this.sprite.y; const distanceSq = dx * dx + dy * dy; - // Priority 5: Chase (hostile + close) - stub for now - if (this.hostile && distanceSq < this.config.hostile.aggroDistanceSq) { - // TODO: Implement chase behavior in future - // return 'chase'; + // Check hostile state from hostile system (overrides config) + const isHostile = window.npcHostileSystem && window.npcHostileSystem.isNPCHostile(this.npcId); + const isKO = window.npcHostileSystem && window.npcHostileSystem.isNPCKO(this.npcId); + + // If KO, always idle + if (isKO) { + return 'idle'; } - // Priority 4: Flee (hostile + far) - stub for now - if (this.hostile) { - // TODO: Implement flee behavior in future - // return 'flee'; + // Priority 5: Chase (hostile + in range) + if (isHostile && distanceSq < this.config.hostile.aggroDistanceSq) { + return 'chase'; } // Priority 3: Maintain Personal Space @@ -964,12 +966,54 @@ class NPCBehavior { } updateHostileBehavior(playerPos, delta) { - if (!this.hostile || !playerPos) return false; + if (!playerPos) return false; - // Stub for future chase/flee implementation - console.log(`[${this.npcId}] Hostile mode active (influence: ${this.influence})`); + // Calculate distance to player + const dx = playerPos.x - this.sprite.x; + const dy = playerPos.y - this.sprite.y; + const distance = Math.sqrt(dx * dx + dy * dy); - return false; // Not actively chasing/fleeing yet + // Get attack range from hostile system + const attackRange = window.npcHostileSystem ? + window.npcHostileSystem.getState(this.npcId)?.attackRange || 50 : 50; + + // If in attack range, try to attack + if (distance <= attackRange) { + // Stop moving + this.sprite.body.setVelocity(0, 0); + this.isMoving = false; + + // Face player + this.direction = this.calculateDirection(dx, dy); + this.playAnimation('idle', this.direction); + + // Attempt attack + if (window.npcCombat) { + window.npcCombat.attemptAttack(this.npcId, this.sprite); + } + + return true; + } + + // Chase player - move towards them + const chaseSpeed = this.config.hostile.chaseSpeed || 120; + + // Calculate normalized direction + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; + + // Set velocity towards player + this.sprite.body.setVelocity( + normalizedDx * chaseSpeed, + normalizedDy * chaseSpeed + ); + + // Calculate and update direction + this.direction = this.calculateDirection(dx, dy); + this.playAnimation('walk', this.direction); + this.isMoving = true; + + return true; } calculateDirection(dx, dy) { diff --git a/js/systems/npc-combat.js b/js/systems/npc-combat.js new file mode 100644 index 0000000..63b640e --- /dev/null +++ b/js/systems/npc-combat.js @@ -0,0 +1,191 @@ +/** + * NPC Combat System + * Handles NPC attacks on the player + */ + +import { COMBAT_CONFIG } from '../config/combat-config.js'; + +export class NPCCombat { + constructor(scene) { + this.scene = scene; + this.npcAttackTimers = new Map(); // npcId -> last attack time + + console.log('â NPC combat system initialized'); + } + + /** + * Check if NPC can attack player + * @param {string} npcId + * @returns {boolean} + */ + canAttack(npcId) { + if (!window.npcHostileSystem) return false; + + const state = window.npcHostileSystem.getState(npcId); + if (!state || !state.isHostile || state.isKO) { + 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(); + const timeSinceLast = now - lastAttackTime; + + return timeSinceLast >= state.attackCooldown; + } + + /** + * Attempt NPC attack on player + * Called by hostile behavior when NPC is in range + * @param {string} npcId + * @param {Phaser.GameObjects.Sprite} npcSprite + * @returns {boolean} - True if attack was initiated + */ + attemptAttack(npcId, npcSprite) { + if (!this.canAttack(npcId)) { + return false; + } + + if (!window.player) { + return false; + } + + const state = window.npcHostileSystem.getState(npcId); + if (!state) return false; + + // Check if player is in range + const distance = Phaser.Math.Distance.Between( + npcSprite.x, + npcSprite.y, + window.player.x, + window.player.y + ); + + if (distance > state.attackRange) { + return false; + } + + // Start attack sequence + this.performAttack(npcId, npcSprite, state); + return true; + } + + /** + * Perform attack sequence + * @param {string} npcId + * @param {Phaser.GameObjects.Sprite} npcSprite + * @param {Object} state - NPC hostile state + */ + performAttack(npcId, npcSprite, state) { + // Update attack timer + this.npcAttackTimers.set(npcId, Date.now()); + + // Show telegraph + if (window.attackTelegraph) { + window.attackTelegraph.show(npcId, npcSprite, COMBAT_CONFIG.npc.attackWindupDuration); + } + + // Play attack animation after windup + this.scene.time.delayedCall(COMBAT_CONFIG.npc.attackWindupDuration, () => { + this.executeAttack(npcId, npcSprite, state); + }); + } + + /** + * Execute the actual attack damage + * @param {string} npcId + * @param {Phaser.GameObjects.Sprite} npcSprite + * @param {Object} state - NPC hostile state + */ + executeAttack(npcId, npcSprite, state) { + if (!window.player || !window.playerHealth) { + return; + } + + // Check if player is still in range + const distance = Phaser.Math.Distance.Between( + npcSprite.x, + npcSprite.y, + window.player.x, + window.player.y + ); + + if (distance > state.attackRange) { + console.log(`${npcId} attack missed - player moved out of range`); + return; + } + + // Play attack animation (placeholder: walk animation with red tint) + this.playAttackAnimation(npcSprite); + + // Apply damage to player + const damage = state.attackDamage; + window.playerHealth.damage(damage); + + // Visual feedback + if (window.spriteEffects) { + window.spriteEffects.flashDamage(window.player); + } + + // Damage numbers + if (window.damageNumbers) { + window.damageNumbers.show(window.player.x, window.player.y - 30, damage, 'damage'); + } + + // Screen effects + if (window.screenEffects) { + window.screenEffects.flashDamage(); + window.screenEffects.shakePlayerHit(); + } + + console.log(`${npcId} dealt ${damage} damage to player`); + } + + /** + * Play attack animation (placeholder) + * @param {Phaser.GameObjects.Sprite} npcSprite + */ + playAttackAnimation(npcSprite) { + if (!npcSprite) return; + + // Apply red tint + if (window.spriteEffects) { + window.spriteEffects.applyAttackTint(npcSprite); + } + + // Play walk animation if available + if (npcSprite.anims && !npcSprite.anims.isPlaying) { + const direction = npcSprite.lastDirection || 'down'; + const animKey = `${npcSprite.npcId}_walk_${direction}`; + if (npcSprite.anims.exists(animKey)) { + npcSprite.play(animKey, true); + } + } + + // Remove tint after animation + this.scene.time.delayedCall(COMBAT_CONFIG.player.punchAnimationDuration, () => { + if (window.spriteEffects) { + window.spriteEffects.clearAttackTint(npcSprite); + } + // Stop animation + if (npcSprite.anims) { + npcSprite.anims.stop(); + } + }); + } + + /** + * Called from game update loop for NPCs in hostile behavior + * @param {string} npcId + * @param {Phaser.GameObjects.Sprite} npcSprite + */ + update(npcId, npcSprite) { + // Attempt attack if possible + this.attemptAttack(npcId, npcSprite); + } +} 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 new file mode 100644 index 0000000..ce657e9 --- /dev/null +++ b/js/systems/npc-hostile.js @@ -0,0 +1,272 @@ +import { COMBAT_CONFIG } from '../config/combat-config.js'; +import { CombatEvents } from '../events/combat-events.js'; + +const npcHostileStates = new Map(); + +function createHostileState(npcId, config = {}) { + return { + isHostile: false, + currentHP: config.maxHP || COMBAT_CONFIG.npc.defaultMaxHP, + maxHP: config.maxHP || COMBAT_CONFIG.npc.defaultMaxHP, + isKO: false, + attackDamage: config.attackDamage || COMBAT_CONFIG.npc.defaultPunchDamage, + attackRange: config.attackRange || COMBAT_CONFIG.npc.defaultPunchRange, + attackCooldown: config.attackCooldown || COMBAT_CONFIG.npc.defaultAttackCooldown, + lastAttackTime: 0 + }; +} + +export function initNPCHostileSystem() { + console.log('â NPC hostile system initialized'); + + return { + setNPCHostile: (npcId, isHostile) => setNPCHostile(npcId, isHostile), + isNPCHostile: (npcId) => isNPCHostile(npcId), + getState: (npcId) => getNPCHostileState(npcId), + damageNPC: (npcId, amount) => damageNPC(npcId, amount), + isNPCKO: (npcId) => isNPCKO(npcId) + }; +} + +function setNPCHostile(npcId, isHostile) { + if (!npcId) { + console.error('setNPCHostile: Invalid NPC ID'); + return false; + } + + // Get or create state + let state = npcHostileStates.get(npcId); + if (!state) { + state = createHostileState(npcId); + npcHostileStates.set(npcId, state); + } + + const wasHostile = state.isHostile; + state.isHostile = 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; +} + +function isNPCHostile(npcId) { + const state = npcHostileStates.get(npcId); + return state ? state.isHostile : false; +} + +function getNPCHostileState(npcId) { + let state = npcHostileStates.get(npcId); + if (!state) { + state = createHostileState(npcId); + npcHostileStates.set(npcId, state); + } + return state; +} + +function damageNPC(npcId, amount) { + const state = getNPCHostileState(npcId); + if (!state) return false; + + if (state.isKO) { + console.log(`NPC ${npcId} already KO`); + return false; + } + + const oldHP = state.currentHP; + state.currentHP = Math.max(0, state.currentHP - amount); + + console.log(`NPC ${npcId} HP: ${oldHP} â ${state.currentHP}`); + + // Check for KO + if (state.currentHP <= 0) { + state.isKO = true; + + // Apply KO visual effect to sprite + const npc = window.npcManager?.getNPC(npcId); + if (npc && npc.sprite && window.spriteEffects) { + 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 }); + } + } + + 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 new file mode 100644 index 0000000..bcffcb1 --- /dev/null +++ b/js/systems/player-combat.js @@ -0,0 +1,309 @@ +/** + * Player Combat System + * Handles player punch attacks on hostile NPCs + */ + +import { COMBAT_CONFIG } from '../config/combat-config.js'; + +export class PlayerCombat { + constructor(scene) { + this.scene = scene; + this.lastPunchTime = 0; + this.isPunching = false; + + console.log('â Player combat system initialized'); + } + + /** + * Check if player can punch (cooldown check) + * @returns {boolean} + */ + canPunch() { + const now = Date.now(); + const timeSinceLast = now - this.lastPunchTime; + return timeSinceLast >= COMBAT_CONFIG.player.punchCooldown; + } + + /** + * Perform punch attack + * This is called when player interacts with a hostile NPC + * Damage applies to ALL NPCs in punch range and facing direction + */ + punch() { + if (this.isPunching || !this.canPunch()) { + console.log('Punch on cooldown'); + return false; + } + + if (!window.player) { + console.error('Player not found'); + return false; + } + + this.isPunching = true; + this.lastPunchTime = Date.now(); + + // Play punch animation (placeholder: walk animation with red tint) + this.playPunchAnimation(); + + // After animation duration, check for hits + this.scene.time.delayedCall(COMBAT_CONFIG.player.punchAnimationDuration, () => { + this.checkForHits(); + this.isPunching = false; + }); + + return true; + } + + /** + * Play punch animation (placeholder) + */ + playPunchAnimation() { + if (!window.player) return; + + // Apply red tint + if (window.spriteEffects) { + window.spriteEffects.applyAttackTint(window.player); + } + + // Play walk animation if not already playing + if (!window.player.anims.isPlaying) { + const direction = window.player.lastDirection || 'down'; + window.player.play(`walk_${direction}`, true); + } + + // Remove tint after animation + this.scene.time.delayedCall(COMBAT_CONFIG.player.punchAnimationDuration, () => { + if (window.spriteEffects) { + window.spriteEffects.clearAttackTint(window.player); + } + // Stop animation + window.player.anims.stop(); + }); + } + + /** + * Check for hits on NPCs in range and direction + * Applies AOE damage to all NPCs in punch range AND facing direction + */ + checkForHits() { + if (!window.player) { + return; + } + + const playerX = window.player.x; + const playerY = window.player.y; + const punchRange = COMBAT_CONFIG.player.punchRange; + const punchDamage = COMBAT_CONFIG.player.punchDamage; + + // Get player facing direction + const direction = window.player.lastDirection || 'down'; + + // 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; + + 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 for chairs in range and direction + let chairsHit = 0; + if (window.chairs && window.chairs.length > 0) { + window.chairs.forEach(chair => { + // Only kick swivel chairs with physics bodies + if (!chair.isSwivelChair || !chair.body) { + return; + } + + const chairX = chair.x; + const chairY = chair.y; + const distance = Phaser.Math.Distance.Between(playerX, playerY, chairX, chairY); + + if (distance > punchRange) { + return; // Too far + } + + // Check if chair is in the facing direction + if (!this.isInDirection(playerX, playerY, chairX, chairY, direction)) { + return; // Not in facing direction + } + + // Hit landed! Kick the chair + this.kickChair(chair); + chairsHit++; + }); + } + + if (hitCount > 0) { + console.log(`Player punch hit ${hitCount} NPC(s)`); + } + if (chairsHit > 0) { + console.log(`Player punch hit ${chairsHit} chair(s)`); + } + if (hitCount === 0 && chairsHit === 0) { + console.log('Player punch missed'); + } + } + + /** + * Check if target is in the player's facing direction + * @param {number} playerX + * @param {number} playerY + * @param {number} targetX + * @param {number} targetY + * @param {string} direction - 'up', 'down', 'left', 'right' + * @returns {boolean} + */ + isInDirection(playerX, playerY, targetX, targetY, direction) { + const dx = targetX - playerX; + const dy = targetY - playerY; + + switch (direction) { + case 'up': + return dy < 0 && Math.abs(dy) > Math.abs(dx); + case 'down': + return dy > 0 && Math.abs(dy) > Math.abs(dx); + case 'left': + return dx < 0 && Math.abs(dx) > Math.abs(dy); + case 'right': + return dx > 0 && Math.abs(dx) > Math.abs(dy); + default: + return false; + } + } + + /** + * Apply damage to NPC + * @param {string|Object} npcIdOrNPC - NPC ID string or NPC object + * @param {number} damage - Damage amount + */ + 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(npcId, damage); + + // Visual feedback + if (npcSprite && window.spriteEffects) { + window.spriteEffects.flashDamage(npcSprite); + } + + // Damage numbers + if (npcSprite && window.damageNumbers) { + window.damageNumbers.show(npcSprite.x, npcSprite.y - 30, damage, 'damage'); + } + + // Screen shake (light) + if (window.screenEffects) { + window.screenEffects.shakeNPCHit(); + } + + console.log(`Dealt ${damage} damage to ${npcId}`); + } + + /** + * Apply kick velocity to chair + * @param {Phaser.GameObjects.Sprite} chair - Chair sprite + */ + kickChair(chair) { + if (!chair || !chair.body || !window.player) { + return; + } + + // Calculate direction from player to chair + const dx = chair.x - window.player.x; + const dy = chair.y - window.player.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > 0) { + // Normalize the direction vector + const dirX = dx / distance; + const dirY = dy / distance; + + // Apply a strong kick velocity + const kickForce = 1200; // Pixels per second + chair.body.setVelocity(dirX * kickForce, dirY * kickForce); + + // Trigger spin direction calculation for visual rotation + if (window.calculateChairSpinDirection) { + window.calculateChairSpinDirection(window.player, chair); + } + + // Visual feedback - flash the chair + if (window.spriteEffects) { + window.spriteEffects.flashHit(chair); + } + + // Light screen shake + if (window.screenEffects) { + window.screenEffects.shake(2, 150); + } + + console.log('CHAIR KICKED', { + chairName: chair.name, + velocity: { x: dirX * kickForce, y: dirY * kickForce } + }); + } + } +} diff --git a/js/systems/player-health.js b/js/systems/player-health.js new file mode 100644 index 0000000..9fe6fd6 --- /dev/null +++ b/js/systems/player-health.js @@ -0,0 +1,79 @@ +import { COMBAT_CONFIG } from '../config/combat-config.js'; +import { CombatEvents } from '../events/combat-events.js'; + +let state = null; + +function createInitialState() { + return { + currentHP: COMBAT_CONFIG.player.maxHP, + maxHP: COMBAT_CONFIG.player.maxHP, + isKO: false + }; +} + +export function initPlayerHealth() { + state = createInitialState(); + console.log('â Player health system initialized'); + + return { + getHP: () => state.currentHP, + getMaxHP: () => state.maxHP, + isKO: () => state.isKO, + damage: (amount) => damagePlayer(amount), + heal: (amount) => healPlayer(amount), + reset: () => { state = createInitialState(); } + }; +} + +function damagePlayer(amount) { + if (!state) { + console.error('Player health not initialized'); + return false; + } + + if (typeof amount !== 'number' || amount < 0) { + console.error('Invalid damage amount:', amount); + return false; + } + + const oldHP = state.currentHP; + state.currentHP = Math.max(0, state.currentHP - amount); + + // Emit HP changed event + if (window.eventDispatcher) { + window.eventDispatcher.emit(CombatEvents.PLAYER_HP_CHANGED, { + hp: state.currentHP, + maxHP: state.maxHP, + delta: -amount + }); + } + + // Check for KO + if (state.currentHP <= 0 && !state.isKO) { + state.isKO = true; + if (window.eventDispatcher) { + window.eventDispatcher.emit(CombatEvents.PLAYER_KO, {}); + } + } + + console.log(`Player HP: ${oldHP} â ${state.currentHP}`); + return true; +} + +function healPlayer(amount) { + if (!state) return false; + + const oldHP = state.currentHP; + state.currentHP = Math.min(state.maxHP, state.currentHP + amount); + + if (window.eventDispatcher) { + window.eventDispatcher.emit(CombatEvents.PLAYER_HP_CHANGED, { + hp: state.currentHP, + maxHP: state.maxHP, + delta: amount + }); + } + + console.log(`Player HP: ${oldHP} â ${state.currentHP}`); + return true; +} diff --git a/js/systems/screen-effects.js b/js/systems/screen-effects.js new file mode 100644 index 0000000..5512ae4 --- /dev/null +++ b/js/systems/screen-effects.js @@ -0,0 +1,95 @@ +/** + * Screen Effects System + * Handles screen flash and shake effects for combat feedback + */ + +export class ScreenEffectsSystem { + constructor(scene) { + this.scene = scene; + this.camera = scene.cameras.main; + + // Flash overlay - full screen colored rectangle + this.flashOverlay = scene.add.rectangle( + 0, 0, + scene.cameras.main.width * 2, + scene.cameras.main.height * 2, + 0xff0000, + 0 + ); + this.flashOverlay.setDepth(10000); // Above everything + this.flashOverlay.setScrollFactor(0); // Fixed to camera + this.flashOverlay.setOrigin(0, 0); + + // Shake state + this.isShaking = false; + this.shakeIntensity = 0; + this.shakeDuration = 0; + this.shakeStartTime = 0; + + console.log('â Screen effects system initialized'); + } + + /** + * Flash the screen with a color + * @param {number} color - Hex color (e.g., 0xff0000 for red) + * @param {number} duration - Duration in ms + * @param {number} maxAlpha - Maximum alpha value (0-1) + */ + flash(color = 0xff0000, duration = 200, maxAlpha = 0.3) { + this.flashOverlay.setFillStyle(color, maxAlpha); + + // Fade out animation + this.scene.tweens.add({ + targets: this.flashOverlay, + alpha: 0, + duration: duration, + ease: 'Cubic.easeOut' + }); + } + + /** + * Shake the camera + * @param {number} intensity - Shake intensity (pixel displacement) + * @param {number} duration - Duration in ms + */ + shake(intensity = 4, duration = 300) { + this.camera.shake(duration, intensity / 1000); // Phaser uses intensity as fraction + } + + /** + * Flash red (damage taken) + */ + flashDamage() { + this.flash(0xff0000, 200, 0.3); + } + + /** + * Flash green (heal) + */ + flashHeal() { + this.flash(0x00ff00, 200, 0.2); + } + + /** + * Screen shake for player taking damage + */ + shakePlayerHit() { + this.shake(6, 300); + } + + /** + * Screen shake for NPC taking damage + */ + shakeNPCHit() { + this.shake(3, 200); + } + + /** + * Clean up system + */ + destroy() { + if (this.flashOverlay) { + this.flashOverlay.destroy(); + } + } +} diff --git a/js/systems/sprite-effects.js b/js/systems/sprite-effects.js new file mode 100644 index 0000000..d0345e9 --- /dev/null +++ b/js/systems/sprite-effects.js @@ -0,0 +1,146 @@ +/** + * Sprite Effects System + * Handles sprite tinting, flashing, and visual effects for combat + */ + +export class SpriteEffectsSystem { + constructor(scene) { + this.scene = scene; + this.activeTints = new Map(); // Track active tint tweens + + console.log('â Sprite effects system initialized'); + } + + /** + * Flash sprite with a tint color + * @param {Phaser.GameObjects.Sprite} sprite - Sprite to flash + * @param {number} color - Tint color + * @param {number} duration - Flash duration in ms + */ + flashTint(sprite, color = 0xff0000, duration = 200) { + if (!sprite || !sprite.active) return; + + // Store original tint + const originalTint = sprite.tint; + + // Apply tint + sprite.setTint(color); + + // Clear any existing tween for this sprite + const existingTween = this.activeTints.get(sprite); + if (existingTween) { + existingTween.remove(); + } + + // Fade back to original + const tween = this.scene.tweens.add({ + targets: sprite, + duration: duration, + onComplete: () => { + sprite.clearTint(); + if (originalTint !== 0xffffff) { + sprite.setTint(originalTint); + } + this.activeTints.delete(sprite); + } + }); + + this.activeTints.set(sprite, tween); + } + + /** + * Flash sprite red (damage) + * @param {Phaser.GameObjects.Sprite} sprite + */ + flashDamage(sprite) { + this.flashTint(sprite, 0xff0000, 200); + } + + /** + * Flash sprite white (hit landed) + * @param {Phaser.GameObjects.Sprite} sprite + */ + flashHit(sprite) { + this.flashTint(sprite, 0xffffff, 150); + } + + /** + * Apply red tint for attack animation + * @param {Phaser.GameObjects.Sprite} sprite + */ + applyAttackTint(sprite) { + if (!sprite || !sprite.active) return; + sprite.setTint(0xff0000); + } + + /** + * Clear attack tint + * @param {Phaser.GameObjects.Sprite} sprite + */ + clearAttackTint(sprite) { + if (!sprite || !sprite.active) return; + sprite.clearTint(); + } + + /** + * Make sprite semi-transparent (KO state) + * @param {Phaser.GameObjects.Sprite} sprite + * @param {number} alpha - Alpha value (0-1) + */ + setKOAlpha(sprite, alpha = 0.5) { + if (!sprite || !sprite.active) return; + sprite.setAlpha(alpha); + } + + /** + * Pulse animation (for telegraphing attacks) + * @param {Phaser.GameObjects.Sprite} sprite + * @param {number} duration - Pulse duration + */ + pulse(sprite, duration = 500) { + if (!sprite || !sprite.active) return; + + this.scene.tweens.add({ + targets: sprite, + scaleX: sprite.scaleX * 1.1, + scaleY: sprite.scaleY * 1.1, + duration: duration / 2, + yoyo: true, + ease: 'Sine.easeInOut' + }); + } + + /** + * Knockback animation + * @param {Phaser.GameObjects.Sprite} sprite + * @param {number} directionX - X direction (-1 or 1) + * @param {number} directionY - Y direction (-1 or 1) + * @param {number} distance - Knockback distance in pixels + */ + knockback(sprite, directionX, directionY, distance = 10) { + if (!sprite || !sprite.active) return; + + const startX = sprite.x; + const startY = sprite.y; + + this.scene.tweens.add({ + targets: sprite, + x: startX + (directionX * distance), + y: startY + (directionY * distance), + duration: 100, + ease: 'Cubic.easeOut', + yoyo: true + }); + } + + /** + * Clean up system + */ + destroy() { + // Stop all active tweens + this.activeTints.forEach(tween => { + if (tween) tween.remove(); + }); + this.activeTints.clear(); + } +} diff --git a/js/ui/game-over-screen.js b/js/ui/game-over-screen.js new file mode 100644 index 0000000..09ea341 --- /dev/null +++ b/js/ui/game-over-screen.js @@ -0,0 +1,183 @@ +/** + * Game Over Screen + * Displayed when player is knocked out (0 HP) + */ + +import { CombatEvents } from '../events/combat-events.js'; + +export class GameOverScreen { + constructor() { + this.overlay = null; + this.isShowing = false; + + this.createUI(); + this.setupEventListeners(); + + console.log('â Game over screen initialized'); + } + + createUI() { + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.id = 'game-over-screen'; + this.overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: none; + justify-content: center; + align-items: center; + z-index: 10000; + flex-direction: column; + gap: 30px; + `; + + // Title + const title = document.createElement('h1'); + title.textContent = 'KNOCKED OUT'; + title.style.cssText = ` + color: #ff0000; + font-size: 64px; + font-family: Arial, sans-serif; + font-weight: bold; + margin: 0; + text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.8); + animation: pulse 2s infinite; + `; + + // Message + const message = document.createElement('p'); + message.textContent = 'You have been defeated'; + message.style.cssText = ` + color: #ffffff; + font-size: 24px; + font-family: Arial, sans-serif; + margin: 0; + `; + + // Buttons container + const buttonsContainer = document.createElement('div'); + buttonsContainer.style.cssText = ` + display: flex; + gap: 20px; + `; + + // Restart button + const restartBtn = document.createElement('button'); + restartBtn.textContent = 'Restart'; + restartBtn.style.cssText = ` + padding: 15px 40px; + font-size: 20px; + font-family: Arial, sans-serif; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background 0.3s; + `; + restartBtn.onmouseover = () => restartBtn.style.background = '#45a049'; + restartBtn.onmouseout = () => restartBtn.style.background = '#4CAF50'; + restartBtn.onclick = () => this.restart(); + + // Main menu button + const menuBtn = document.createElement('button'); + menuBtn.textContent = 'Main Menu'; + menuBtn.style.cssText = ` + padding: 15px 40px; + font-size: 20px; + font-family: Arial, sans-serif; + background: #555; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background 0.3s; + `; + menuBtn.onmouseover = () => menuBtn.style.background = '#666'; + menuBtn.onmouseout = () => menuBtn.style.background = '#555'; + menuBtn.onclick = () => this.mainMenu(); + + buttonsContainer.appendChild(restartBtn); + buttonsContainer.appendChild(menuBtn); + + this.overlay.appendChild(title); + this.overlay.appendChild(message); + this.overlay.appendChild(buttonsContainer); + + // Add CSS animation for pulse + const style = document.createElement('style'); + style.textContent = ` + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.8; transform: scale(1.05); } + } + `; + document.head.appendChild(style); + + document.body.appendChild(this.overlay); + } + + setupEventListeners() { + if (!window.eventDispatcher) { + console.warn('Event dispatcher not found'); + return; + } + + // Listen for player KO + window.eventDispatcher.on(CombatEvents.PLAYER_KO, () => { + this.show(); + }); + } + + show() { + if (!this.isShowing) { + this.overlay.style.display = 'flex'; + this.isShowing = true; + + // Disable player movement + if (window.player) { + window.player.disableMovement = true; + } + } + } + + hide() { + if (this.isShowing) { + this.overlay.style.display = 'none'; + this.isShowing = false; + } + } + + restart() { + // Reset player health + if (window.playerHealth) { + window.playerHealth.reset(); + } + + // Re-enable player movement + if (window.player) { + window.player.disableMovement = false; + } + + // Hide game over screen + this.hide(); + + // Reload the page to restart + window.location.reload(); + } + + mainMenu() { + // Navigate to scenario select or main menu + window.location.href = 'scenario_select.html'; + } + + destroy() { + if (this.overlay && this.overlay.parentNode) { + this.overlay.parentNode.removeChild(this.overlay); + } + } +} diff --git a/js/ui/health-ui.js b/js/ui/health-ui.js new file mode 100644 index 0000000..601d6c5 --- /dev/null +++ b/js/ui/health-ui.js @@ -0,0 +1,120 @@ +/** + * Health UI System + * Displays player health as hearts above the inventory + */ + +import { COMBAT_CONFIG } from '../config/combat-config.js'; +import { CombatEvents } from '../events/combat-events.js'; + +export class HealthUI { + constructor() { + this.container = null; + this.hearts = []; + this.currentHP = COMBAT_CONFIG.player.maxHP; + this.maxHP = COMBAT_CONFIG.player.maxHP; + this.isVisible = false; + + this.createUI(); + this.setupEventListeners(); + + console.log('â Health UI initialized'); + } + + createUI() { + // Create main container div + this.container = document.createElement('div'); + this.container.id = 'health-ui-container'; + + // Create hearts container + const heartsContainer = document.createElement('div'); + 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('img'); + heart.className = 'health-heart'; + heart.src = 'assets/icons/heart.png'; + heart.alt = 'HP'; + heartsContainer.appendChild(heart); + this.hearts.push(heart); + } + + this.container.appendChild(heartsContainer); + document.body.appendChild(this.container); + + // Initially hide (only show when damaged) + this.hide(); + } + + setupEventListeners() { + if (!window.eventDispatcher) { + console.warn('Event dispatcher not found, health UI will not update automatically'); + return; + } + + // Listen for HP changes + window.eventDispatcher.on(CombatEvents.PLAYER_HP_CHANGED, (data) => { + this.updateHP(data.hp, data.maxHP); + }); + + // Listen for player KO + window.eventDispatcher.on(CombatEvents.PLAYER_KO, () => { + this.show(); // Always show when KO + }); + } + + updateHP(hp, maxHP) { + this.currentHP = hp; + this.maxHP = maxHP; + + // Show UI if damaged + if (hp < maxHP) { + this.show(); + } else { + this.hide(); + } + + // Update heart visuals + const heartsPerHP = maxHP / COMBAT_CONFIG.ui.maxHearts; // 20 HP per heart (100 / 5) + const fullHearts = Math.floor(hp / heartsPerHP); + const remainder = hp % heartsPerHP; + const halfHeart = remainder >= (heartsPerHP / 2); + + this.hearts.forEach((heart, index) => { + if (index < fullHearts) { + // Full heart + heart.src = 'assets/icons/heart.png'; + heart.style.opacity = '1'; + } else if (index === fullHearts && halfHeart) { + // Half heart + heart.src = 'assets/icons/heart-half.png'; + heart.style.opacity = '1'; + } else { + // Empty heart + heart.src = 'assets/icons/heart.png'; + heart.style.opacity = '0.2'; + } + }); + } + + show() { + if (!this.isVisible) { + this.container.style.display = 'flex'; + this.isVisible = true; + } + } + + hide() { + if (this.isVisible) { + this.container.style.display = 'none'; + this.isVisible = false; + } + } + + destroy() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + } +} diff --git a/js/ui/npc-health-bars.js b/js/ui/npc-health-bars.js new file mode 100644 index 0000000..2552a4d --- /dev/null +++ b/js/ui/npc-health-bars.js @@ -0,0 +1,199 @@ +/** + * NPC Health Bars System + * Displays health bars above hostile NPCs + */ + +import { COMBAT_CONFIG } from '../config/combat-config.js'; +import { CombatEvents } from '../events/combat-events.js'; + +export class NPCHealthBars { + constructor(scene) { + this.scene = scene; + this.healthBars = new Map(); // npcId -> { background, bar, npcSprite } + + this.setupEventListeners(); + + console.log('â NPC health bars initialized'); + } + + setupEventListeners() { + if (!window.eventDispatcher) { + console.warn('Event dispatcher not found'); + return; + } + + 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 { + this.removeHealthBar(data.npcId); + } + }); + + // Listen for NPC KO + window.eventDispatcher.on(CombatEvents.NPC_KO, (data) => { + console.log('đĨ NPCHealthBars: Received NPC_KO event', data); + this.removeHealthBar(data.npcId); + }); + } + + createHealthBar(npcId) { + // Don't create duplicate + if (this.healthBars.has(npcId)) { + return; + } + + // Get NPC sprite + const npcSprite = this.getNPCSprite(npcId); + if (!npcSprite) { + console.warn(`Cannot create health bar for ${npcId}: sprite not found`); + 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; + + // Create background (dark gray) + const background = this.scene.add.rectangle( + npcSprite.x, + npcSprite.y + offsetY, + width, + height, + 0x333333 + ); + background.setDepth(850); + background.setStrokeStyle(1, 0x000000); + + // Create health bar (red to green gradient based on HP) + const bar = this.scene.add.rectangle( + npcSprite.x, + npcSprite.y + offsetY, + width, + height, + 0x00ff00 + ); + bar.setDepth(851); + + this.healthBars.set(npcId, { + background, + bar, + npcSprite + }); + + // Initial update + this.updateHealthBar(npcId); + } + + updateHealthBar(npcId) { + const healthBar = this.healthBars.get(npcId); + if (!healthBar) return; + + // Get NPC health state + if (!window.npcHostileSystem) return; + const state = window.npcHostileSystem.getState(npcId); + if (!state) { + console.warn(`đĨ No state for ${npcId}`); + return; + } + + // Calculate HP percentage + 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 - 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); + + // 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); + + // Always use red for NPC health bar + healthBar.bar.setFillStyle(0xff0000); // Red + } + + removeHealthBar(npcId) { + const healthBar = this.healthBars.get(npcId); + if (!healthBar) return; + + // Destroy graphics + if (healthBar.background) healthBar.background.destroy(); + if (healthBar.bar) healthBar.bar.destroy(); + + this.healthBars.delete(npcId); + } + + update() { + // Update positions to follow NPCs + this.healthBars.forEach((healthBar, npcId) => { + if (!healthBar.npcSprite || !healthBar.npcSprite.active) { + // NPC sprite is gone, clean up + this.removeHealthBar(npcId); + return; + } + + const offsetY = COMBAT_CONFIG.ui.healthBarOffsetY; + + // Update positions + healthBar.background.setPosition( + healthBar.npcSprite.x, + healthBar.npcSprite.y + offsetY + ); + + // Update health bar (it will recalculate position) + this.updateHealthBar(npcId); + }); + } + + getNPCSprite(npcId) { + // 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; + } + + destroy() { + // Remove all health bars + this.healthBars.forEach((_, npcId) => { + this.removeHealthBar(npcId); + }); + this.healthBars.clear(); + } +} diff --git a/js/utils/combat-debug.js b/js/utils/combat-debug.js new file mode 100644 index 0000000..626e64b --- /dev/null +++ b/js/utils/combat-debug.js @@ -0,0 +1,136 @@ +/** + * Debug utilities for combat system + * Access via window.CombatDebug in browser console + */ + +export function initCombatDebug() { + window.CombatDebug = { + // Player health testing + testPlayerHealth() { + console.log('=== Testing Player Health ==='); + if (!window.playerHealth) { + console.error('Player health system not initialized'); + return; + } + + console.log('Initial HP:', window.playerHealth.getHP()); + window.playerHealth.damage(20); + console.log('After 20 damage:', window.playerHealth.getHP()); + window.playerHealth.damage(50); + console.log('After 50 more damage:', window.playerHealth.getHP()); + window.playerHealth.heal(30); + console.log('After 30 heal:', window.playerHealth.getHP()); + window.playerHealth.reset(); + console.log('After reset:', window.playerHealth.getHP()); + }, + + // NPC hostile testing + testNPCHostile(npcId = 'security_guard') { + console.log(`=== Testing NPC Hostile (${npcId}) ===`); + if (!window.npcHostileSystem) { + console.error('NPC hostile system not initialized'); + return; + } + + window.npcHostileSystem.setNPCHostile(npcId, true); + console.log('Is hostile:', window.npcHostileSystem.isNPCHostile(npcId)); + + window.npcHostileSystem.damageNPC(npcId, 30); + const state = window.npcHostileSystem.getState(npcId); + console.log('NPC HP:', state.currentHP, '/', state.maxHP); + console.log('Is KO:', state.isKO); + }, + + // Get player HP + getPlayerHP() { + if (!window.playerHealth) { + console.error('Player health system not initialized'); + return null; + } + return window.playerHealth.getHP(); + }, + + // Set player HP + setPlayerHP(hp) { + if (!window.playerHealth) { + console.error('Player health system not initialized'); + return; + } + const current = window.playerHealth.getHP(); + const delta = hp - current; + if (delta > 0) { + window.playerHealth.heal(delta); + } else if (delta < 0) { + window.playerHealth.damage(-delta); + } + console.log(`Player HP set to ${hp}`); + }, + + // Make NPC hostile + makeHostile(npcId) { + if (!window.npcHostileSystem) { + console.error('NPC hostile system not initialized'); + return; + } + window.npcHostileSystem.setNPCHostile(npcId, true); + console.log(`${npcId} is now hostile`); + }, + + // Make NPC peaceful + makePeaceful(npcId) { + if (!window.npcHostileSystem) { + console.error('NPC hostile system not initialized'); + return; + } + window.npcHostileSystem.setNPCHostile(npcId, false); + console.log(`${npcId} is now peaceful`); + }, + + // Get NPC state + getNPCState(npcId) { + if (!window.npcHostileSystem) { + console.error('NPC hostile system not initialized'); + return null; + } + return window.npcHostileSystem.getState(npcId); + }, + + // Damage NPC + damageNPC(npcId, amount) { + if (!window.npcHostileSystem) { + console.error('NPC hostile system not initialized'); + return; + } + window.npcHostileSystem.damageNPC(npcId, amount); + const state = window.npcHostileSystem.getState(npcId); + console.log(`${npcId} HP: ${state.currentHP}/${state.maxHP}`); + }, + + // Show all systems status + status() { + console.log('=== Combat Systems Status ==='); + console.log('Player Health:', window.playerHealth ? 'â ' : 'â'); + console.log('NPC Hostile System:', window.npcHostileSystem ? 'â ' : 'â'); + console.log('Player Combat:', window.playerCombat ? 'â ' : 'â'); + console.log('NPC Combat:', window.npcCombat ? 'â ' : 'â'); + console.log('Event Dispatcher:', window.eventDispatcher ? 'â ' : 'â'); + + if (window.playerHealth) { + console.log('Player HP:', window.playerHealth.getHP()); + } + }, + + // Run all tests + runAll() { + this.testPlayerHealth(); + console.log(''); + this.testNPCHostile(); + console.log(''); + this.status(); + } + }; + + console.log('â Combat debug utilities loaded'); + console.log('Use window.CombatDebug.runAll() to run all tests'); + console.log('Use window.CombatDebug.status() to check system status'); +} diff --git a/js/utils/error-handling.js b/js/utils/error-handling.js new file mode 100644 index 0000000..1a33e2b --- /dev/null +++ b/js/utils/error-handling.js @@ -0,0 +1,56 @@ +/** + * Error handling utilities for combat system + */ + +export function validateNumber(value, name, min = -Infinity, max = Infinity) { + if (typeof value !== 'number' || isNaN(value)) { + console.error(`${name} must be a valid number, got:`, value); + return false; + } + if (value < min || value > max) { + console.error(`${name} must be between ${min} and ${max}, got:`, value); + return false; + } + return true; +} + +export function validateNPCId(npcId) { + if (!npcId || typeof npcId !== 'string') { + console.error('Invalid NPC ID:', npcId); + return false; + } + return true; +} + +export function validateNPCExists(npcId) { + if (!validateNPCId(npcId)) return false; + + if (!window.npcManager) { + console.error('NPC Manager not initialized'); + return false; + } + + const npc = window.npcManager.getNPC(npcId); + if (!npc) { + console.error(`NPC not found: ${npcId}`); + return false; + } + + return true; +} + +export function validateSystem(systemName, windowProperty) { + if (!window[windowProperty]) { + console.error(`${systemName} not initialized (window.${windowProperty} is undefined)`); + return false; + } + return true; +} + +export function logCombatError(context, error) { + console.error(`[Combat Error] ${context}:`, error); +} + +export function logCombatWarning(context, message) { + console.warn(`[Combat Warning] ${context}:`, message); +} diff --git a/planning_notes/npc/hostile/CORRECTIONS.md b/planning_notes/npc/hostile/CORRECTIONS.md new file mode 100644 index 0000000..85d3c28 --- /dev/null +++ b/planning_notes/npc/hostile/CORRECTIONS.md @@ -0,0 +1,371 @@ +# Corrections to Planning Documents + +## Issue: Incorrect Ink Pattern Usage + +### Problem + +Several planning documents show examples using `-> END` after `#exit_conversation`, which is **incorrect** based on the existing codebase patterns. + +### Correct Pattern + +Based on existing Ink files (e.g., `helper-npc.ink`), the correct pattern is: + +```ink +=== some_knot === +# speaker:npc +Dialogue here... +# exit_conversation +-> hub +``` + +**NOT:** +```ink +=== some_knot === +# speaker:npc +Dialogue here... +# exit_conversation +-> END +``` + +### Key Principle + +**NEVER use `-> END`** in our Ink files. **ALWAYS use `-> hub`** to return to the hub, even after `#exit_conversation`. + +The `#exit_conversation` tag tells the game engine to close the conversation UI, but the Ink flow still needs to resolve to a valid state (the hub). + +--- + +## Exit Conversation Tag - Already Implemented â + +**Good News**: The `#exit_conversation` tag is **already handled** in the codebase. + +**Location**: `/js/minigames/person-chat/person-chat-minigame.js` line 537: +```javascript +const shouldExit = result?.tags?.some(tag => tag.includes('exit_conversation')); +``` + +When this tag is detected, the minigame: +1. Shows the NPC's final response +2. Schedules the conversation to close +3. Saves the NPC conversation state +4. Exits the minigame + +**No additional handler needed** for `#exit_conversation` - it works out of the box. + +--- + +## Hostile Tag - Needs Implementation â + +**Required**: The `#hostile` tag needs to be added to the tag processing system. + +**Location**: `/js/minigames/helpers/chat-helpers.js` + +**Where to Add**: In the `processGameActionTags()` function switch statement (around line 60), add: + +```javascript +case 'hostile': { + const npcId = param || window.currentConversationNPCId; + + if (!npcId) { + result.message = 'â ī¸ hostile tag missing NPC ID'; + console.warn(result.message); + break; + } + + console.log(`đ´ Processing hostile tag for NPC: ${npcId}`); + + // Set NPC to hostile state + if (window.npcHostileSystem) { + window.npcHostileSystem.setNPCHostile(npcId, true); + result.success = true; + result.message = `â ī¸ ${npcId} is now hostile!`; + } else { + result.message = 'â ī¸ Hostile system not initialized'; + console.warn(result.message); + } + + // Emit event for other systems + if (window.eventDispatcher) { + window.eventDispatcher.emit('npc_became_hostile', { npcId }); + } + + break; +} +``` + +**Tag Format**: +- `#hostile:npcId` - Make specific NPC hostile +- `#hostile` - Make current conversation NPC hostile (uses `window.currentConversationNPCId`) + +--- + +## Files Needing Correction + +### 1. implementation_plan.md + +**Lines 613, 623** - Example code shows: +```ink +# hostile:security_guard +# exit_conversation +-> END +``` + +**Should be:** +```ink +# hostile:security_guard +# exit_conversation +-> hub +``` + +**Line 627** - Instructions say: +> Replace `-> END` with either `-> hub` or `# exit_conversation` + `-> END` + +**Should say:** +> Replace `-> END` with either `-> hub` (to continue conversation) or `# exit_conversation` + `-> hub` (to exit conversation) + +--- + +### 2. phase0_foundation.md + +**Test Ink File Example** - Shows: +```ink +=== test_hostile === +# speaker:test_npc +This will trigger hostile mode! +# hostile:security_guard +# exit_conversation +You should now be in combat. +-> END + +=== test_exit === +# speaker:test_npc +This will exit cleanly. +# exit_conversation +Goodbye! +-> END +``` + +**Should be:** +```ink +=== test_hostile === +# speaker:test_npc +Triggering hostile state for security guard! +Watch out - they're coming for you! +# hostile:security_guard +# exit_conversation +-> hub + +=== test_exit === +# speaker:test_npc +Exiting the conversation cleanly. +Goodbye, and good luck! +# exit_conversation +-> hub +``` + +**Note**: The dialogue should come BEFORE the `#exit_conversation` tag, as the conversation closes when that tag is processed. Text after the tag won't be shown. + +--- + +### 3. implementation_roadmap.md + +**Phase 7.2 section** - References exit_conversation tag handler needing to be added. This should be removed since it already exists. + +--- + +## Corrected Security Guard Ink Pattern + +Here's the correct pattern for updating `security-guard.ink`: + +### Current (Incorrect) +```ink +=== 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 +``` + +### Corrected +```ink +=== 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 +# hostile:security_guard +# exit_conversation +-> hub +``` + +### Current (Incorrect) +```ink +=== escalate_conflict === +# speaker:security_guard +~ influence -= 40 +You've crossed the line! This is a lockdown! +INTRUDER ALERT! INTRUDER ALERT! +# display:guard-alarm +-> END +``` + +### Corrected +```ink +=== escalate_conflict === +# speaker:security_guard +~ influence -= 40 +You've crossed the line! This is a lockdown! +INTRUDER ALERT! INTRUDER ALERT! +# display:guard-alarm +# hostile:security_guard +# exit_conversation +-> hub +``` + +--- + +## Additional Security Guard Updates Needed + +The current `security-guard.ink` file has **8 instances of `-> END`** that need to be addressed: + +### Lines needing updates: +- Line 83: `explain_drop` (low influence path) +- Line 99: `claim_official` (low influence path) +- Line 119: `explain_situation` (low influence path) +- Line 134: `explain_files` (low influence path) +- Line 150: `explain_audit` (low influence path) +- Line 159: `hostile_response` +- Line 167: `escalate_conflict` +- Line 180: `back_down` + +### Decision Matrix + +For each `-> END`, decide: + +1. **Should conversation continue?** â Use `-> hub` +2. **Should conversation exit cleanly?** â Use `# exit_conversation` + `-> hub` +3. **Should NPC become hostile?** â Use `# hostile:security_guard` + `# exit_conversation` + `-> hub` + +### Recommendations + +**Hostile paths (lines 159, 167)**: +- Add `# hostile:security_guard` tag +- Add `# exit_conversation` tag +- Change `-> END` to `-> hub` + +**Negative outcome paths that should exit (lines 83, 99, 119, 134, 150)**: +- These are "you've been caught/failed" paths +- Add `# exit_conversation` tag +- Change `-> END` to `-> hub` +- Player can still re-talk to NPC if needed + +**Back down path (line 180)**: +- This seems like it should exit conversation +- Add `# exit_conversation` tag +- Change `-> END` to `-> hub` + +--- + +## Corrected Test Ink File + +**File**: `/scenarios/ink/test-hostile.ink` + +```ink +// test-hostile.ink +// Simple test for hostile tag system + +VAR test_count = 0 + +=== start === +# speaker:test_npc +~ test_count += 1 +Welcome to the hostile tag test. +-> hub + +=== hub === ++ [Test hostile tag] + -> test_hostile ++ [Test exit conversation] + -> test_exit ++ [Loop back to start] + -> start + +=== test_hostile === +# speaker:test_npc +This will trigger hostile mode for the security guard! +Watch out - they're coming for you! +# hostile:security_guard +# exit_conversation +-> hub + +=== test_exit === +# speaker:test_npc +This will exit the conversation cleanly. +Goodbye, and good luck! +# exit_conversation +-> hub +``` + +--- + +## Summary of Corrections + +1. **Never use `-> END`** - Always use `-> hub` +2. **Exit pattern**: `# exit_conversation` followed by `-> hub` (already works!) +3. **Hostile pattern**: `# hostile:npcId` + `# exit_conversation` + `-> hub` +4. **Hub pattern**: All conversation paths eventually return to hub +5. **Multiple exits**: A conversation can have multiple exit points, all using the same pattern +6. **Exit conversation already implemented**: No need to add handler, it already exists in person-chat-minigame.js + +--- + +## Why This Pattern? + +From analyzing `helper-npc.ink` and `person-chat-minigame.js`: + +- The hub acts as a central conversation state +- `#exit_conversation` is a **tag** that tells the game engine to close the UI +- This tag is **already detected** in person-chat-minigame.js +- The Ink story still needs to resolve to a valid state (the hub) +- Returning to hub after exit means the NPC state is properly saved +- If player talks to NPC again, conversation starts at `start` knot, not hub +- This pattern allows for proper state management and prevents Ink errors + +--- + +## Action Items + +When implementing: + +1. â Read this corrections document first +2. â Never use `-> END` in any Ink file +3. â Follow the corrected patterns above +4. â **Don't add** exit_conversation handler - it already exists! +5. â **Do add** hostile tag handler to chat-helpers.js +6. â Test each conversation path thoroughly +7. â Verify `#exit_conversation` closes the UI (should work already) +8. â Verify returning to hub doesn't cause issues +9. â Update security-guard.ink according to recommendations +10. â Create test-hostile.ink with corrected pattern + +--- + +## References + +- **Good Example**: `/scenarios/ink/helper-npc.ink` - Perfect hub pattern usage +- **Needs Fixing**: `/scenarios/ink/security-guard.ink` - Has 8 `-> END` instances +- **Exit Tag Implementation**: `/js/minigames/person-chat/person-chat-minigame.js` line 537 +- **Tag Processing**: `/js/minigames/helpers/chat-helpers.js` - Add hostile case here +- **Pattern Source**: Lines 68-71 of `helper-npc.ink`: + ```ink + + [Thanks, I'm good for now.] + # speaker:npc + Alright then. Let me know if you need anything else! + #exit_conversation + -> hub + ``` + +This is the canonical pattern we should follow everywhere. diff --git a/planning_notes/npc/hostile/FORMAT_REVIEW.md b/planning_notes/npc/hostile/FORMAT_REVIEW.md new file mode 100644 index 0000000..b367bb5 --- /dev/null +++ b/planning_notes/npc/hostile/FORMAT_REVIEW.md @@ -0,0 +1,391 @@ +# Format Review - JSON Scenarios and Ink Files + +## Review Summary + +This document reviews all JSON scenario and Ink file examples in the planning documents against the actual codebase formats. + +--- + +## 1. Ink File Format Review + +### â Correct Pattern (from `helper-npc.ink`) + +**Hub Pattern:** +```ink +=== start === +# speaker:npc +Initial dialogue +-> hub + +=== hub === ++ [Choice 1] + -> knot1 ++ [Choice 2] + -> knot2 ++ [Exit choice] + # speaker:npc + Goodbye message + #exit_conversation + -> hub + +=== knot1 === +# speaker:npc +Dialogue +-> hub +``` + +**Key Rules:** +1. â Always `-> hub` (NEVER `-> END`) +2. â `#exit_conversation` tag to close UI +3. â Even after `#exit_conversation`, use `-> hub` +4. â Hub is the central conversation state +5. â `start` knot is entry point, immediately goes to hub + +### â Issues Found in Planning Documents + +**implementation_plan.md (Lines ~605-625):** +- â Shows `# exit_conversation` followed by `-> END` +- â Should be `# exit_conversation` followed by `-> hub` + +**phase0_foundation.md (Test Ink File):** +- â Shows `-> END` in multiple places +- â Should be `-> hub` everywhere + +**Corrected in:** `CORRECTIONS.md` + +--- + +## 2. JSON Scenario Format Review + +### â Correct NPC Format (from `npc-patrol-lockpick.json`) + +**Complete NPC Definition:** +```json +{ + "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 } + ], + "speed": 40, + "pauseTime": 10 + } + }, + "los": { + "enabled": true, + "range": 150, + "angle": 140, + "visualize": true + }, + "eventMappings": [ + { + "eventPattern": "lockpick_used_in_view", + "targetKnot": "on_lockpick_used", + "conversationMode": "person-chat", + "cooldown": 0 + } + ] +} +``` + +**Required Fields:** +- `id` - Unique NPC identifier (string) +- `displayName` - Name shown to player (string) +- `npcType` - Type of NPC (usually "person" for sprite NPCs) +- `position` - { x, y } in pixels or tiles depending on context +- `spriteSheet` - Name of sprite sheet (without extension) +- `storyPath` - Path to compiled Ink JSON file +- `currentKnot` - Starting knot (usually "start") + +**Optional Fields:** +- `spriteTalk` - Path to talking sprite variant +- `spriteConfig` - Animation frame configuration + - `idleFrameStart` - First frame of idle animation + - `idleFrameEnd` - Last frame of idle animation + - `idleFrame` - Single frame for static idle (alternative) +- `behavior` - Behavior configuration object + - `facePlayer` - Boolean or object with distance + - `patrol` - Patrol configuration +- `los` - Line of sight configuration + - `enabled` - Boolean + - `range` - Detection range in pixels + - `angle` - Field of view angle in degrees + - `visualize` - Show debug visualization +- `eventMappings` - Array of event-to-conversation mappings + - `eventPattern` - Event name to listen for + - `targetKnot` - Ink knot to jump to + - `conversationMode` - Type of conversation ("person-chat", "phone", etc.) + - `cooldown` - Cooldown in milliseconds + +### đ Review of Planning Document Examples + +**phase0_foundation.md Test NPC (Lines 352-362):** +```json +{ + "id": "test_npc", + "displayName": "Test Dummy", + "npcType": "person", + "spriteSheet": "hacker", + "storyPath": "scenarios/ink/test-hostile.json", + "currentKnot": "start", + "position": { "x": 100, "y": 100 }, + "roomId": "test_room" +} +``` + +**Review:** +- â Has all required fields +- â Correct JSON structure +- â ī¸ Has `roomId` field - this is **not needed** in the NPC object itself + - NPCs are defined **inside** room objects in the scenario + - The room context is implicit +- â ī¸ Missing optional but useful fields: + - `spriteConfig` for idle animation + - Could add minimal `behavior` for testing + - Could add minimal `los` for hostile testing + +**Corrected Example:** +```json +{ + "id": "test_npc", + "displayName": "Test Dummy", + "npcType": "person", + "position": { "x": 100, "y": 100 }, + "spriteSheet": "hacker", + "spriteConfig": { + "idleFrameStart": 20, + "idleFrameEnd": 23 + }, + "storyPath": "scenarios/ink/test-hostile.json", + "currentKnot": "start", + "behavior": { + "patrol": { + "enabled": false + } + } +} +``` + +--- + +## 3. Complete Scenario Structure + +### â Correct Full Scenario Format + +**From `npc-patrol-lockpick.json`:** + +```json +{ + "scenario_brief": "Brief description", + "endGoal": "Goal description", + "startRoom": "room_id", + + "player": { + "id": "player", + "displayName": "Player Name", + "spriteSheet": "hacker", + "spriteTalk": "assets/characters/hacker-talk.png", + "spriteConfig": { + "idleFrameStart": 20, + "idleFrameEnd": 23 + } + }, + + "rooms": { + "room_id": { + "type": "room_type", + "connections": { + "north": "other_room_id" + }, + "npcs": [ + { /* NPC objects here */ } + ], + "objects": [ + { /* Object definitions here */ } + ] + } + } +} +``` + +**Top-Level Fields:** +- `scenario_brief` - Description shown to player +- `endGoal` - Win condition description +- `startRoom` - ID of starting room +- `startItemsInInventory` - Array of items (optional) +- `player` - Player configuration (optional) +- `rooms` - Object with room definitions + +**Room Fields:** +- `type` - Room tilemap type (e.g., "room_office", "room_reception") +- `connections` - Object mapping directions to room IDs + - Valid directions: "north", "south", "east", "west" +- `locked` - Boolean (optional) +- `lockType` - "key", "pin", "password", etc. (if locked) +- `requires` - Key ID or password/PIN (if locked) +- `keyPins` - Array of lock pins for lockpicking (if lockType is "key") +- `difficulty` - "easy", "medium", "hard" (for lockpicking) +- `door_sign` - Text shown on door (optional) +- `npcs` - Array of NPC objects +- `objects` - Array of object definitions + +--- + +## 4. New NPC Fields for Hostile System + +### Proposed Addition + +For NPCs that can become hostile, add optional `hostile` configuration: + +```json +{ + "id": "tough_guard", + "displayName": "Elite Guard", + "npcType": "person", + "position": { "x": 5, "y": 5 }, + "spriteSheet": "hacker-red", + "storyPath": "scenarios/ink/tough-guard.json", + "currentKnot": "start", + "behavior": { + "patrol": { + "enabled": true, + "speed": 100 + } + }, + "los": { + "enabled": true, + "range": 200, + "angle": 120 + }, + "hostile": { + "maxHP": 150, + "attackDamage": 15, + "attackRange": 60, + "attackCooldown": 1500, + "chaseSpeed": 140 + } +} +``` + +**New `hostile` Object Fields:** +- `maxHP` - Maximum health points (default: 100 from config) +- `attackDamage` - Damage per attack (default: 10 from config) +- `attackRange` - Attack range in pixels (default: 50 from config) +- `attackCooldown` - Cooldown between attacks in ms (default: 2000 from config) +- `chaseSpeed` - Movement speed when chasing in pixels/second (default: 120 from config) + +**Notes:** +- All fields are **optional** +- If not specified, defaults from `COMBAT_CONFIG` are used +- Allows per-NPC customization of combat stats +- Can be added to any existing NPC without breaking anything + +--- + +## 5. Summary of Issues and Corrections + +### Issues Found + +1. â **Ink Pattern**: Planning docs show `-> END` after `#exit_conversation` + - **Fix**: Always use `-> hub` (see CORRECTIONS.md) + +2. â ī¸ **Test NPC JSON**: Includes `roomId` field + - **Fix**: Remove `roomId` (NPCs are already inside room objects) + - **Enhancement**: Add `spriteConfig` for completeness + +3. â **JSON Structure**: Overall structure matches codebase + - All required fields present + - Correct nesting and format + +### Corrections Applied + +- Created `CORRECTIONS.md` with detailed Ink pattern fixes +- This document provides correct JSON format reference +- Updated test NPC example above with corrections + +--- + +## 6. Checklist for Implementation + +When implementing the hostile NPC feature: + +### Ink Files +- [ ] Never use `-> END` anywhere +- [ ] Always use `-> hub` to return to hub +- [ ] Use `#exit_conversation` tag to close UI +- [ ] After `#exit_conversation`, still use `-> hub` +- [ ] Test all conversation paths return to hub +- [ ] Verify `#hostile:npcId` tag works as expected + +### JSON Scenarios +- [ ] NPCs defined inside `rooms.{roomId}.npcs` array +- [ ] All required fields present (id, displayName, npcType, position, spriteSheet, storyPath, currentKnot) +- [ ] Don't add `roomId` to NPC objects (redundant) +- [ ] Add `spriteConfig` for proper idle animations +- [ ] Add `los` configuration for hostile NPCs +- [ ] Add `eventMappings` for lockpick detection if needed +- [ ] Optionally add `hostile` object for custom combat stats +- [ ] Verify JSON is valid (no trailing commas, proper quotes) + +### Testing +- [ ] Create test scenario with test NPC +- [ ] Verify conversation loads without errors +- [ ] Test hostile tag triggers hostile state +- [ ] Verify conversation exits properly with `#exit_conversation` +- [ ] Check no Ink errors in console +- [ ] Verify NPC state persists correctly + +--- + +## 7. References + +**Good Examples to Follow:** +- `/scenarios/ink/helper-npc.ink` - Perfect hub pattern +- `/scenarios/npc-patrol-lockpick.json` - Complete NPC scenario + +**Files Needing Updates:** +- `/scenarios/ink/security-guard.ink` - Has 8 `-> END` that need fixing + +**Planning Documents:** +- `CORRECTIONS.md` - Detailed corrections for Ink patterns +- `implementation_plan.md` - Main implementation guide (note Ink corrections) +- `phase0_foundation.md` - Foundation setup (note JSON/Ink corrections) + +--- + +## Conclusion + +### Format Compliance + +| Format | Status | Notes | +|--------|--------|-------| +| JSON Scenario Structure | â Correct | Matches existing patterns | +| NPC Object Format | â Mostly Correct | Minor improvement: remove roomId | +| Ink Hub Pattern | â Incorrect in docs | Fixed in CORRECTIONS.md | +| Event Tags | â Correct | Proper tag usage | +| Required Fields | â Complete | All fields present | +| Optional Fields | â ī¸ Could improve | Add spriteConfig, hostile config | + +### Action Items + +1. **Read CORRECTIONS.md first** before implementing any Ink files +2. **Use this document** as JSON format reference +3. **Follow helper-npc.ink** as the canonical Ink example +4. **Test thoroughly** with a simple test scenario first +5. **Validate JSON** before loading (use JSON linter) + +With these corrections applied, all formats will match the existing codebase patterns and work correctly. diff --git a/planning_notes/npc/hostile/architecture.md b/planning_notes/npc/hostile/architecture.md new file mode 100644 index 0000000..831eacd --- /dev/null +++ b/planning_notes/npc/hostile/architecture.md @@ -0,0 +1,776 @@ +# NPC Hostile State - Architecture Overview + +## System Architecture Diagram + +``` +âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ +â Game Loop (main.js) â +â ââââââââââââââââ âââââââââââââââââ ââââââââââââââââââââââ â +â â create() â â update() â â Event Listeners â â +â ââââââââââââââââ âââââââââââââââââ ââââââââââââââââââââââ â +âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ + â â â + â Initialize â Update â React to Events + âŧ âŧ âŧ +âââââââââââââââââââ âââââââââââââââââââ ââââââââââââââââââââ +â Health Systems â â Combat Systems â â UI Systems â +ââââââââââââââââââ⤠ââââââââââââââââââ⤠âââââââââââââââââââ⤠+â Player Health â â Player Combat â â Player Health UI â +â NPC Hostile â â NPC Combat â â NPC Health UI â +â â â Combat Anims â â Game Over UI â +âââââââââââââââââââ âââââââââââââââââââ ââââââââââââââââââââ + â â â + ââââââââââââââââââââââ´âââââââââââââââââââââââââ + â + âŧ + ââââââââââââââââââââ + â NPC Behaviors â + âââââââââââââââââââ⤠+ â Patrol (Normal) â + â Chase (Hostile) â + â Attack (Combat) â + ââââââââââââââââââââ + â + âŧ + ââââââââââââââââââââ + â LOS System â + âââââââââââââââââââ⤠+ â Player Detection â + â Visual Range â + ââââââââââââââââââââ + â + âŧ + ââââââââââââââââââââ + â Ink Integration â + âââââââââââââââââââ⤠+ â Tag Processing â + â Hostile Trigger â + ââââââââââââââââââââ +``` + +## Core Systems + +### 1. Health Management + +**Player Health System** (`player-health.js`) +- **Responsibility**: Track player HP, damage, healing, KO state +- **Data**: Current HP (0-100), max HP, KO flag +- **Events Emitted**: + - `player_hp_changed` - When HP changes + - `player_ko` - When HP reaches 0 +- **Used By**: Combat system, UI system, player controls + +**NPC Hostile State System** (`npc-hostile.js`) +- **Responsibility**: Track hostile state and health for all NPCs +- **Data Structure**: Map of npcId â state object + - `isHostile`: Boolean + - `currentHP`: Number (0-maxHP) + - `maxHP`: Number (configurable per NPC) + - `isKO`: Boolean + - `attackCooldown`: Number (ms) + - `chaseTarget`: Reference to player + - `attackDamage`: Number (configurable) +- **Events Emitted**: + - `npc_hostile_state_changed` - When hostile state toggles + - `npc_ko` - When NPC HP reaches 0 +- **Used By**: Behavior system, combat system, UI system, Ink integration + +### 2. Combat Systems + +**Player Combat System** (`player-combat.js`) +- **Responsibility**: Handle player punching attacks +- **State**: Punch cooldown, isPunching flag +- **Process Flow**: + 1. Player inputs punch command (SPACE key) + 2. Check cooldowns and state + 3. Play punch animation (walk + red tint) + 4. Wait animation duration (500ms) + 5. Check target still in range + 6. Apply damage to NPC + 7. Update NPC health state + 8. Start cooldown +- **Dependencies**: + - Animation system + - NPC hostile system + - Combat config + - Player state + +**NPC Combat System** (`npc-combat.js`) +- **Responsibility**: Handle NPC attacks on player +- **State**: Per-NPC attack cooldowns (in hostile state) +- **Process Flow**: + 1. NPC behavior detects player in range + 2. Check attack cooldown + 3. Stop NPC movement + 4. Play attack animation (walk + red tint) + 5. Wait animation duration + 6. Check player still in range + 7. Apply damage to player + 8. Update player health + 9. Start cooldown + 10. Resume NPC movement +- **Dependencies**: + - Animation system + - Player health system + - NPC hostile system + - Combat config + +**Combat Animation System** (`combat-animations.js`) +- **Responsibility**: Play placeholder punch animations +- **Technique**: Reuse walk animations with red tint +- **Future**: Will be replaced with dedicated punch sprites +- **Functions**: + - `playPlayerPunchAnimation()` - Returns promise + - `playNPCPunchAnimation()` - Returns promise + - Both handle tinting, animation, and cleanup + +### 3. Behavior System Integration + +**NPC Behavior Manager** (`npc-behavior.js` - MODIFIED) + +Current behavior modes: +- **Normal Mode**: Patrol within bounds, face player +- **Hostile Mode** (NEW): Chase player, attack when in range + +**Hostile Behavior Flow**: +``` +âââââââââââââââââââââââââââ +â NPC Becomes Hostile â +ââââââââââââââŦâââââââââââââ + â + âŧ +âââââââââââââââââââââââââââ +â Enable LOS (360°) â +ââââââââââââââŦâââââââââââââ + â + âŧ +âââââââââââââââââââââââââââ +â Is Player in LOS? â +ââââââŦâââââââââââââââŦââââââ + â Yes â No + âŧ âŧ +âââââââââââââââ âââââââââââââââ +â Chase Playerâ â Keep Patrol â +ââââââââŦâââââââ âââââââââââââââ + â + âŧ +ââââââââââââââââââââââââ +â Distance < Attack? â +ââââŦâââââââââââââââŦâââââ + â Yes â No + âŧ âŧ +ââââââââââââ ââââââââââââ +â Attack â â Continue â +ââââââââââââ ââââââââââââ +``` + +**Integration Points**: +1. `updateNPCBehaviors()` - Check hostile state before behavior +2. `updateHostileBehavior()` - NEW function for chase/attack +3. `moveNPCTowardsTarget()` - NEW function using pathfinding +4. Uses existing pathfinding system + +### 4. Line of Sight (LOS) System + +**LOS for Hostile NPCs** (`npc-los.js` - EXTENDED) + +**Current System**: +- Detects player in cone-shaped field of view +- Configurable range and angle +- Used for lockpicking detection + +**Hostile Extensions**: +- Dynamic LOS enabling when NPC becomes hostile +- 360-degree vision for hostile NPCs (vs 120° normal) +- Continuous player tracking +- Integration with chase behavior + +**New Functions**: +- `enableNPCLOS(npc, range, angle)` - Turn on LOS dynamically +- `setNPCLOSTracking(npc, isTracking)` - Toggle tracking mode + +### 5. UI Systems + +**Player Health UI** (`player-health-ui.js`) + +Display Method: +- Heart icons above inventory +- 5 hearts maximum +- Full heart = 20 HP +- Half heart = 10 HP +- Empty heart = 0 HP + +Example Displays: +- 100 HP: â¤ī¸â¤ī¸â¤ī¸â¤ī¸â¤ī¸ +- 70 HP: â¤ī¸â¤ī¸â¤ī¸đđ¤ +- 30 HP: â¤ī¸đđ¤đ¤đ¤ +- 10 HP: đđ¤đ¤đ¤đ¤ + +Visibility: +- Hidden when HP = 100 (full health) +- Shows when HP < 100 +- Updates in real-time on damage/healing + +**NPC Health Bar UI** (`npc-health-ui.js`) + +Display Method: +- Phaser Graphics object above NPC sprite +- Green fill for current HP +- Red/black background +- White border +- 60x6 pixels +- Positioned 40px above sprite + +Lifecycle: +- Created when NPC becomes hostile +- Updated when NPC takes damage +- Follows NPC movement (updated each frame) +- Destroyed when NPC is KO + +**Game Over UI** (`game-over-ui.js`) + +Display: +- Full-screen overlay +- Semi-transparent black background +- Centered content box +- "GAME OVER" message +- Restart button + +Triggered: +- Player HP reaches 0 +- Player becomes KO +- Player movement disabled + +### 6. Ink Dialogue Integration + +**Tag Processing** (`chat-helpers.js` - MODIFIED) + +New Tag: `#hostile` or `#hostile:npcId` + +**Processing Flow**: +``` +Ink Story Reaches Hostile Path + â +Tag: #hostile:security_guard + â +processGameActionTags() + â +processHostileTag(tag, ui) + â +Extract NPC ID from tag + â +npcHostileSystem.setNPCHostile(npcId, true) + â +Emit 'npc_became_hostile' event + â +Exit conversation (#exit_conversation) + â +Player back in game world + â +NPC begins hostile behavior +``` + +**Tag Usage in Ink**: +```ink +=== escalate_conflict === +# speaker:security_guard +You've crossed the line! This is a lockdown! +# hostile:security_guard +# exit_conversation +-> END +``` + +**Security Guard Updates**: +- All paths use hub pattern or `#exit_conversation` +- Hostile paths trigger `#hostile` tag +- Conversation exits immediately after hostile trigger +- No more dead-end `-> END` without cleanup + +## Data Flow Diagrams + +### Player Damage Flow + +``` +NPC in Attack Range + â +canNPCAttack() â true + â +npcAttack(npcId, npc) + â +Play Attack Animation (500ms) + â +Check Player Still in Range + â +damagePlayer(attackDamage) + â +playerHP -= damage + â +Emit 'player_hp_changed' + â +updatePlayerHealthUI() + â +Calculate Hearts from HP + â +Render Hearts + â +Check if HP <= 0 + â +setPlayerKO(true) + â +Emit 'player_ko' + â +showGameOver() + â +Disable Player Movement +``` + +### NPC Becomes Hostile Flow + +``` +Player Chooses Hostile Dialogue Option + â +Ink Reaches Hostile Knot + â +Tag: #hostile:security_guard + â +processHostileTag(tag, ui) + â +setNPCHostile('security_guard', true) + â +Update npcHostileStates Map + â +Emit 'npc_became_hostile' + â +Event Listener Triggered + â +enableNPCLOS(npc, 400, 360) + â +createNPCHealthBar(npcId, npc) + â +Exit Conversation + â +Update Loop Detects Hostile State + â +Switch to updateHostileBehavior() + â +Check Player in LOS + â +If Yes: Chase Player + â +If in Attack Range: Attack Player +``` + +### Player Punches NPC Flow + +``` +Player Near Hostile NPC + â +Press SPACE Key + â +canPlayerPunch() â true + â +Get Facing Direction + â +playPlayerPunchAnimation() + â +Apply Red Tint + Walk Animation + â +Wait 500ms + â +Clear Tint + Return to Idle + â +Check NPC Still in Range + â +If Yes: damageNPC(npcId, damage) + â +npcHP -= damage + â +updateNPCHealthBar(npcId, currentHP, maxHP) + â +Redraw Health Bar Fill + â +Check if npcHP <= 0 + â +If Yes: setNPCKO(npcId, true) + â +Emit 'npc_ko' + â +replaceWithKOSprite(scene, npc) + â +Gray Tinted + Rotated Sprite + â +destroyNPCHealthBar(npcId) + â +Disable NPC Behavior Updates +``` + +## Configuration System + +**Central Configuration** (`combat-config.js`) + +All combat parameters in one place for easy tuning: + +```javascript +{ + player: { + maxHP: 100, + punchDamage: 20, + punchRange: 60, + punchCooldown: 1000 + }, + npc: { + defaultMaxHP: 100, + defaultPunchDamage: 10, + chaseSpeed: 120, + attackRange: 50 + }, + ui: { + maxHearts: 5, + healthBarWidth: 60, + healthBarHeight: 6 + } +} +``` + +**Why Centralized?** +- Easy balancing and tuning +- Consistent values across systems +- No magic numbers in code +- Quick iteration during playtesting + +## State Management + +### Global State Extensions + +**New Window Objects**: +- `window.playerHealth` - Player health system instance +- `window.npcHostileSystem` - NPC hostile state manager +- `window.playerCombat` - Player combat system +- `window.npcCombat` - NPC combat system +- `window.currentPunchTarget` - Currently targetable NPC for punch + +**Existing State Used**: +- `window.player` - Player sprite reference +- `window.npcManager` - NPC registry +- `window.eventDispatcher` - Event bus +- `window.currentRoom` - Current room ID +- `window.pathfinders` - Pathfinding per room + +## Event System + +### New Events + +| Event Name | Payload | Emitted By | Listeners | +|------------|---------|------------|-----------| +| `player_hp_changed` | `{ hp, maxHP }` | player-health.js | player-health-ui.js | +| `player_ko` | `{ }` | player-health.js | game-over-ui.js, player.js | +| `npc_hostile_state_changed` | `{ npcId, isHostile }` | npc-hostile.js | npc-behavior.js, npc-health-ui.js | +| `npc_became_hostile` | `{ npcId }` | chat-helpers.js | main.js (setup LOS, health bar) | +| `npc_ko` | `{ npcId }` | npc-hostile.js | npc-ko-sprites.js, npc-health-ui.js | + +### Event Flow Example + +``` +Player Takes Damage + â +damagePlayer(10) + â +playerHP: 100 â 90 + â +eventDispatcher.emit('player_hp_changed', { hp: 90, maxHP: 100 }) + â +player-health-ui.js receives event + â +updatePlayerHealthUI() + â +calculateHearts(90) â 4.5 hearts + â +Render: â¤ī¸â¤ī¸â¤ī¸â¤ī¸đ + â +showPlayerHealthUI() (was hidden at 100 HP) +``` + +## Module Dependencies + +### Dependency Graph + +``` +main.js + ââ> player-health.js + â ââ> (no dependencies) + â + ââ> player-health-ui.js + â ââ> player-health.js + â + ââ> npc-hostile.js + â ââ> combat-config.js + â + ââ> npc-health-ui.js + â ââ> npc-hostile.js + â + ââ> game-over-ui.js + â ââ> (no dependencies) + â + ââ> player-combat.js + â ââ> player-health.js + â ââ> npc-hostile.js + â ââ> combat-animations.js + â ââ> combat-config.js + â + ââ> npc-combat.js + â ââ> player-health.js + â ââ> npc-hostile.js + â ââ> combat-animations.js + â ââ> combat-config.js + â + ââ> combat-animations.js + â ââ> combat-config.js + â + ââ> npc-ko-sprites.js + â ââ> (Phaser only) + â + ââ> npc-behavior.js (MODIFIED) + ââ> npc-hostile.js + ââ> npc-los.js + ââ> npc-pathfinding.js (existing) + ââ> combat-config.js +``` + +### Load Order + +1. **Configuration** (no dependencies) + - `combat-config.js` + +2. **Core Systems** (config only) + - `player-health.js` + - `npc-hostile.js` + +3. **Animation & Sprites** + - `combat-animations.js` + - `npc-ko-sprites.js` + +4. **Combat Mechanics** (core + animation) + - `player-combat.js` + - `npc-combat.js` + +5. **UI Systems** (core + combat) + - `player-health-ui.js` + - `npc-health-ui.js` + - `game-over-ui.js` + +6. **Behavior Extensions** (all above) + - `npc-behavior.js` (modified) + - `npc-los.js` (modified) + +7. **Integration** (all above) + - `interactions.js` (modified) + - `player.js` (modified) + - `chat-helpers.js` (modified) + - `main.js` (modified) + +## Performance Considerations + +### Update Loop Optimization + +**Every Frame**: +- Check hostile NPC interactions (limited to current room) +- Update NPC health bar positions (only for hostile NPCs) +- Player/NPC collision detection (existing) + +**Throttled Updates** (existing 50ms): +- NPC behavior updates +- Pathfinding calculations + +**On-Demand**: +- Health UI updates (only on HP change events) +- Game over screen (only on player KO) +- Hostile state changes (only via Ink tags or events) + +### Memory Management + +**Cleanup When**: +- NPC becomes KO â Destroy health bar graphics +- Player leaves room â Health bars for that room +- Game restarts â Reset all combat state + +**Persistent State**: +- Hostile state per NPC (persists across rooms) +- Player HP (persists across rooms) +- NPC HP (persists while NPC exists) + +### Optimization Strategies + +1. **Lazy Initialization**: Health bars only created when hostile +2. **Event-Driven UI**: Updates only on state changes +3. **Spatial Partitioning**: Only check NPCs in current room +4. **Object Pooling**: Reuse graphics objects when possible +5. **Throttling**: Behavior updates at 50ms intervals + +## Extension Points + +### Future Enhancements + +**Easy to Add**: +- Different NPC types with different HP/damage +- Weapons that modify player damage +- Power-ups that heal player +- Special attacks with different animations +- Block/dodge mechanics +- Combo system + +**Requires More Work**: +- Multiplayer combat +- Ranged attacks +- Cover system +- Stealth kills +- Different damage types +- Status effects (stun, slow, etc.) + +### Customization Per NPC + +NPCs can be configured with custom combat stats: + +```javascript +// In scenario JSON +{ + "id": "tough_guard", + "hostile": { + "maxHP": 150, + "attackDamage": 15, + "attackRange": 60, + "chaseSpeed": 140 + } +} +``` + +System will use these values instead of defaults from config. + +## Testing Strategy + +### Unit Testing Focus + +1. **Health Systems** + - HP bounds checking (0-100) + - Damage calculation + - Healing calculation + - KO state triggers + +2. **Combat Systems** + - Cooldown timers + - Range checks + - Animation timing + - Damage application + +3. **State Management** + - Hostile state toggle + - State persistence + - State retrieval + +### Integration Testing Focus + +1. **Ink â Hostile State** + - Tag processing + - State update + - Event emission + +2. **Hostile â Behavior** + - LOS activation + - Chase logic + - Attack triggers + +3. **Combat â UI** + - Health display updates + - Health bar rendering + - Game over trigger + +### Manual Testing Focus + +1. **Gameplay Feel** + - Combat responsiveness + - Animation clarity + - Visual feedback + - Difficulty balance + +2. **Edge Cases** + - Rapid attacks + - Out-of-range attempts + - Multiple hostile NPCs + - Room transitions + +## Security Considerations + +### Input Validation + +- Damage values clamped to reasonable ranges +- HP values bounded (0-max) +- NPC IDs validated before state access +- Cooldowns enforced client-side + +### State Integrity + +- HP cannot go negative +- HP cannot exceed max +- Cooldowns cannot be bypassed +- KO state immutable until reset + +### Cheat Prevention + +Not a focus for single-player game, but architecture allows: +- Server-authoritative HP (if multiplayer added) +- Damage verification +- Cooldown verification +- State synchronization + +## Troubleshooting Guide + +### Common Issues + +**Hearts Not Showing** +- Check: HP < 100? +- Check: playerHealthUI initialized? +- Check: CSS z-index correct? +- Check: Event listener attached? + +**NPC Not Chasing** +- Check: NPC is hostile? +- Check: LOS enabled? +- Check: Player in LOS range? +- Check: Pathfinder for room exists? + +**Punch Not Working** +- Check: Near hostile NPC? +- Check: Cooldown finished? +- Check: Player not KO? +- Check: SPACE key bound? + +**Health Bar Missing** +- Check: NPC is hostile? +- Check: Health bar created on hostile event? +- Check: Graphics visible in scene? +- Check: Positioned correctly? + +### Debug Helpers + +Add these to window for debugging: + +```javascript +window.debugCombat = { + getPlayerHP: () => window.playerHealth.getPlayerHP(), + setPlayerHP: (hp) => window.playerHealth.setPlayerHP(hp), + makeHostile: (npcId) => window.npcHostileSystem.setNPCHostile(npcId, true), + getNPCState: (npcId) => window.npcHostileSystem.getNPCHostileState(npcId), + showAllHealthBars: () => { /* force show all */ }, + resetCombat: () => { /* reset all combat state */ } +}; +``` + +## Summary + +This architecture provides: +- **Modular Design**: Each system has clear responsibilities +- **Event-Driven**: Loose coupling between systems +- **Extensible**: Easy to add new features +- **Configurable**: Tunable parameters for balancing +- **Testable**: Clear interfaces and dependencies +- **Performant**: Optimized update loops and cleanup +- **Maintainable**: Clear code organization and documentation diff --git a/planning_notes/npc/hostile/enhanced_combat_feedback.md b/planning_notes/npc/hostile/enhanced_combat_feedback.md new file mode 100644 index 0000000..7f7ff1c --- /dev/null +++ b/planning_notes/npc/hostile/enhanced_combat_feedback.md @@ -0,0 +1,752 @@ +# Enhanced Combat Feedback Implementation + +## Overview + +This document details the implementation of strong visual and audio feedback for combat actions, addressing the primary UX concern of clarity and responsiveness. + +## Visual Feedback System + +### 1. Damage Numbers + +**File**: `/js/systems/damage-numbers.js` (NEW) + +Floating damage numbers that appear when entities take damage: + +```javascript +import { COMBAT_CONFIG } from '../config/combat-config.js'; + +class DamageNumberPool { + constructor(scene, poolSize = 20) { + this.scene = scene; + this.pool = []; + this.active = []; + + // Pre-create pool + for (let i = 0; i < poolSize; i++) { + this.pool.push(this.createDamageNumber()); + } + } + + createDamageNumber() { + const text = this.scene.add.text(0, 0, '', { + fontSize: '24px', + fontFamily: 'Arial Black, Arial', + color: '#ffffff', + stroke: '#000000', + strokeThickness: 4 + }); + text.setVisible(false); + text.setDepth(1000); // Above everything + return text; + } + + show(x, y, damage, isCritical = false, isMiss = false) { + // Get from pool or create new + let text = this.pool.pop() || this.createDamageNumber(); + + if (isMiss) { + // Miss display + text.setText('MISS'); + text.setColor('#888888'); + text.setScale(1); + } else { + // Damage number + text.setText(`-${Math.floor(damage)}`); + text.setColor(isCritical ? '#ff0000' : '#ffffff'); + text.setScale(isCritical ? 1.5 : 1); + } + + text.setPosition(x - text.width / 2, y); + text.setVisible(true); + text.setAlpha(1); + + this.active.push(text); + + // Animate up and fade + this.scene.tweens.add({ + targets: text, + y: y - COMBAT_CONFIG.ui.damageNumberRise, + alpha: 0, + duration: COMBAT_CONFIG.ui.damageNumberDuration, + ease: 'Cubic.easeOut', + onComplete: () => { + this.recycle(text); + } + }); + } + + recycle(text) { + text.setVisible(false); + const index = this.active.indexOf(text); + if (index > -1) { + this.active.splice(index, 1); + } + if (this.pool.length < 20) { // Max pool size + this.pool.push(text); + } else { + text.destroy(); // Pool full, destroy excess + } + } + + destroy() { + [...this.pool, ...this.active].forEach(text => text.destroy()); + this.pool = []; + this.active = []; + } +} + +// Initialize +export function initDamageNumbers(scene) { + const pool = new DamageNumberPool(scene); + + // Add to window for global access + window.damageNumbers = { + show: (x, y, damage, isCritical, isMiss) => { + pool.show(x, y, damage, isCritical, isMiss); + }, + destroy: () => pool.destroy() + }; + + return pool; +} +``` + +**Usage**: +```javascript +// When damage applied +window.damageNumbers?.show(npc.sprite.x, npc.sprite.y, 20, false, false); + +// When attack misses +window.damageNumbers?.show(npc.sprite.x, npc.sprite.y, 0, false, true); +``` + +--- + +### 2. Screen Flash Effect + +**File**: `/js/systems/screen-effects.js` (NEW) + +Screen flash for player damage feedback: + +```javascript +export function initScreenEffects(scene) { + // Create overlay for flashes + const overlay = scene.add.rectangle( + 0, 0, + scene.cameras.main.width, + scene.cameras.main.height, + 0xff0000, // Red + 0 // Initially invisible + ); + overlay.setOrigin(0, 0); + overlay.setDepth(999); // Below damage numbers, above game + overlay.setScrollFactor(0); // Fixed to camera + + window.screenEffects = { + flashDamage() { + if (!COMBAT_CONFIG.feedback.enableScreenFlash) return; + + overlay.setAlpha(0.3); + scene.tweens.add({ + targets: overlay, + alpha: 0, + duration: COMBAT_CONFIG.ui.screenFlashDuration, + ease: 'Cubic.easeOut' + }); + }, + + flashHeal() { + overlay.fillColor = 0x00ff00; // Green + overlay.setAlpha(0.2); + scene.tweens.add({ + targets: overlay, + alpha: 0, + duration: 300, + ease: 'Cubic.easeOut', + onComplete: () => { + overlay.fillColor = 0xff0000; // Back to red + } + }); + }, + + flashWarning() { + overlay.fillColor = 0xffaa00; // Orange + overlay.setAlpha(0.2); + scene.tweens.add({ + targets: overlay, + alpha: 0, + duration: 200, + ease: 'Cubic.easeOut', + onComplete: () => { + overlay.fillColor = 0xff0000; // Back to red + } + }); + } + }; + + return window.screenEffects; +} +``` + +**Usage**: +```javascript +// When player takes damage +window.screenEffects?.flashDamage(); + +// When player heals +window.screenEffects?.flashHeal(); + +// When hostile NPC attacks (wind-up) +window.screenEffects?.flashWarning(); +``` + +--- + +### 3. Screen Shake Effect + +**File**: `/js/systems/screen-effects.js` (ADD TO ABOVE) + +Add to the same file: + +```javascript +// Add to window.screenEffects object +window.screenEffects.shake = function(intensity = null) { + if (!COMBAT_CONFIG.feedback.enableScreenShake) return; + + const shakeAmount = intensity || COMBAT_CONFIG.ui.screenShakeIntensity; + + scene.cameras.main.shake(100, shakeAmount / 1000); // Duration ms, intensity 0-1 +}; + +// Shake with different intensities +window.screenEffects.shakeLight = function() { + this.shake(2); +}; + +window.screenEffects.shakeMedium = function() { + this.shake(4); +}; + +window.screenEffects.shakeHeavy = function() { + this.shake(6); +}; +``` + +**Usage**: +```javascript +// Light damage +window.screenEffects?.shakeLight(); + +// Medium damage +window.screenEffects?.shakeMedium(); + +// Heavy damage / KO +window.screenEffects?.shakeHeavy(); +``` + +--- + +### 4. Sprite Flash Effects + +**File**: `/js/systems/sprite-effects.js` (NEW) + +Reusable sprite visual effects: + +```javascript +export function flashSprite(sprite, color = 0xffffff, duration = 100) { + if (!sprite) return; + + const originalTint = sprite.tintTopLeft; + + sprite.setTint(color); + + sprite.scene.time.delayedCall(duration, () => { + sprite.clearTint(); + if (originalTint !== 0xffffff) { + sprite.setTint(originalTint); + } + }); +} + +export function flashSpriteRepeat(sprite, color = 0xff0000, times = 3, duration = 100) { + if (!sprite) return; + + let count = 0; + const interval = sprite.scene.time.addEvent({ + delay: duration * 2, + callback: () => { + flashSprite(sprite, color, duration); + count++; + if (count >= times) { + interval.destroy(); + } + }, + repeat: times - 1 + }); +} + +export function shakeSprite(sprite, intensity = 5, duration = 100) { + if (!sprite) return; + + const originalX = sprite.x; + const originalY = sprite.y; + + sprite.scene.tweens.add({ + targets: sprite, + x: originalX + Phaser.Math.Between(-intensity, intensity), + y: originalY + Phaser.Math.Between(-intensity, intensity), + duration: duration / 4, + yoyo: true, + repeat: 3, + onComplete: () => { + sprite.setPosition(originalX, originalY); + } + }); +} +``` + +**Usage**: +```javascript +import { flashSprite, shakeSprite } from './sprite-effects.js'; + +// When NPC hit +flashSprite(npc.sprite, 0xffffff, 100); + +// When player hit +flashSprite(window.player, 0xff0000, 300); + +// When NPC KO'd +flashSpriteRepeat(npc.sprite, 0x666666, 3, 150); +``` + +--- + +### 5. Attack Telegraph Visuals + +**File**: `/js/systems/attack-telegraph.js` (NEW) + +Visual indicators for NPC attacks: + +```javascript +export class AttackTelegraph { + constructor(scene, npc) { + this.scene = scene; + this.npc = npc; + + // Create exclamation mark + this.icon = scene.add.text(0, 0, '!', { + fontSize: '32px', + fontFamily: 'Arial Black', + color: '#ff0000', + stroke: '#ffffff', + strokeThickness: 3 + }); + this.icon.setOrigin(0.5, 1); + this.icon.setVisible(false); + this.icon.setDepth(100); + + // Create attack range indicator + this.rangeCircle = scene.add.circle(0, 0, 50, 0xff0000, 0.2); + this.rangeCircle.setStrokeStyle(2, 0xff0000, 0.8); + this.rangeCircle.setVisible(false); + this.rangeCircle.setDepth(1); + } + + show() { + this.updatePosition(); + this.icon.setVisible(true); + this.rangeCircle.setVisible(true); + + // Pulse animation + this.scene.tweens.add({ + targets: this.icon, + scaleX: 1.2, + scaleY: 1.2, + duration: 200, + yoyo: true, + repeat: -1 // Infinite while showing + }); + + // Range circle expand + this.scene.tweens.add({ + targets: this.rangeCircle, + scaleX: 1.2, + scaleY: 1.2, + alpha: 0.4, + duration: 250, + yoyo: true, + repeat: -1 + }); + } + + hide() { + this.icon.setVisible(false); + this.rangeCircle.setVisible(false); + this.scene.tweens.killTweensOf(this.icon); + this.scene.tweens.killTweensOf(this.rangeCircle); + this.icon.setScale(1); + this.rangeCircle.setScale(1); + } + + updatePosition() { + if (this.npc.sprite) { + const x = this.npc.sprite.x; + const y = this.npc.sprite.y - 50; // Above NPC + + this.icon.setPosition(x, y); + this.rangeCircle.setPosition(this.npc.sprite.x, this.npc.sprite.y); + } + } + + destroy() { + this.icon.destroy(); + this.rangeCircle.destroy(); + } +} + +// Create for NPC +export function createAttackTelegraph(scene, npc) { + return new AttackTelegraph(scene, npc); +} +``` + +**Usage**: +```javascript +// In NPC hostile state initialization +npc.attackTelegraph = createAttackTelegraph(scene, npc); + +// When NPC begins attack wind-up +npc.attackTelegraph.show(); + +// During wind-up, update position +npc.attackTelegraph.updatePosition(); + +// When attack executes or cancelled +npc.attackTelegraph.hide(); +``` + +--- + +## Audio Feedback System + +### 6. Sound Effect Manager + +**File**: `/js/systems/combat-sounds.js` (NEW) + +Manage combat sound effects: + +```javascript +export class CombatSounds { + constructor(scene) { + this.scene = scene; + this.enabled = COMBAT_CONFIG.feedback.enableSounds; + + // Preload sound effects (assuming they're loaded in scene preload) + this.sounds = { + playerPunch: null, + npcPunch: null, + hit: null, + miss: null, + playerHurt: null, + playerKO: null, + npcKO: null, + warning: null + }; + } + + init() { + // Load or reference sounds + // Assuming sounds are already loaded in scene + try { + this.sounds.playerPunch = this.scene.sound.add('punch'); + this.sounds.npcPunch = this.scene.sound.add('punch'); + this.sounds.hit = this.scene.sound.add('hit'); + this.sounds.miss = this.scene.sound.add('whoosh'); + this.sounds.playerHurt = this.scene.sound.add('hurt'); + this.sounds.playerKO = this.scene.sound.add('ko'); + this.sounds.npcKO = this.scene.sound.add('ko'); + this.sounds.warning = this.scene.sound.add('warning'); + } catch (e) { + console.warn('Some combat sounds not loaded:', e); + } + } + + playPlayerPunch() { + if (this.enabled && this.sounds.playerPunch) { + this.sounds.playerPunch.play({ volume: 0.5 }); + } + } + + playNPCPunch() { + if (this.enabled && this.sounds.npcPunch) { + this.sounds.npcPunch.play({ volume: 0.5 }); + } + } + + playHit() { + if (this.enabled && this.sounds.hit) { + this.sounds.hit.play({ volume: 0.6 }); + } + } + + playMiss() { + if (this.enabled && this.sounds.miss) { + this.sounds.miss.play({ volume: 0.3 }); + } + } + + playPlayerHurt() { + if (this.enabled && this.sounds.playerHurt) { + this.sounds.playerHurt.play({ volume: 0.7 }); + } + } + + playPlayerKO() { + if (this.enabled && this.sounds.playerKO) { + this.sounds.playerKO.play({ volume: 0.8 }); + } + } + + playNPCKO() { + if (this.enabled && this.sounds.npcKO) { + this.sounds.npcKO.play({ volume: 0.6 }); + } + } + + playWarning() { + if (this.enabled && this.sounds.warning) { + this.sounds.warning.play({ volume: 0.5 }); + } + } + + setEnabled(enabled) { + this.enabled = enabled; + } +} + +export function initCombatSounds(scene) { + const sounds = new CombatSounds(scene); + sounds.init(); + + window.combatSounds = sounds; + + return sounds; +} +``` + +**Sound Asset Loading** (in scene preload): +```javascript +// Add to scene preload() method +preload() { + // Placeholder sounds (replace with actual assets) + // You can use free sound effects or generate placeholder audio + this.load.audio('punch', 'assets/sounds/punch.mp3'); + this.load.audio('hit', 'assets/sounds/hit.mp3'); + this.load.audio('whoosh', 'assets/sounds/whoosh.mp3'); + this.load.audio('hurt', 'assets/sounds/hurt.mp3'); + this.load.audio('ko', 'assets/sounds/ko.mp3'); + this.load.audio('warning', 'assets/sounds/warning.mp3'); +} +``` + +**Note**: For MVP, sound effects can be skipped or use placeholder sounds. The system is built to gracefully handle missing sounds. + +--- + +## Integration into Combat Systems + +### 7. Enhanced Player Combat + +**Update**: `/js/systems/player-combat.js` + +Add feedback to player punching: + +```javascript +export async function playerPunch(targetNPC) { + if (!canPlayerPunch()) return; + + // Play punch sound + window.combatSounds?.playPlayerPunch(); + + // Get direction + const direction = getPlayerFacingDirection(); + + // Play punch animation + await playPlayerPunchAnimation(scene, player, direction); + + // Check if NPC still in range + const distance = Phaser.Math.Distance.Between( + window.player.x, window.player.y, + targetNPC.sprite.x, targetNPC.sprite.y + ); + + if (distance <= COMBAT_CONFIG.player.punchRange) { + // HIT + const damage = COMBAT_CONFIG.player.punchDamage; + + window.combatSounds?.playHit(); + window.npcHostileSystem.damageNPC(targetNPC.id, damage); + + // Visual feedback + flashSprite(targetNPC.sprite, 0xffffff, 100); + shakeSprite(targetNPC.sprite, 5, 100); + window.damageNumbers?.show( + targetNPC.sprite.x, + targetNPC.sprite.y - 20, + damage, + false, + false + ); + } else { + // MISS + window.combatSounds?.playMiss(); + window.damageNumbers?.show( + targetNPC.sprite.x, + targetNPC.sprite.y - 20, + 0, + false, + true + ); + } + + // Start cooldown + startPunchCooldown(); +} +``` + +--- + +### 8. Enhanced NPC Combat + +**Update**: `/js/systems/npc-combat.js` + +Add feedback to NPC attacking: + +```javascript +export async function npcAttack(npcId, npc) { + const state = window.npcHostileSystem.getNPCHostileState(npcId); + if (!state) return; + + // Show attack telegraph + if (npc.attackTelegraph) { + npc.attackTelegraph.show(); + } + + // Play warning + window.combatSounds?.playWarning(); + window.screenEffects?.flashWarning(); + + // Wind-up delay (gives player time to react) + await new Promise(resolve => + setTimeout(resolve, COMBAT_CONFIG.npc.attackWindupDuration) + ); + + // Hide telegraph + if (npc.attackTelegraph) { + npc.attackTelegraph.hide(); + } + + // Play attack sound + window.combatSounds?.playNPCPunch(); + + // Play attack animation + const direction = getNPCFacingDirection(npc); + await playNPCPunchAnimation(scene, npc, direction); + + // Check if player still in range + const playerPos = { x: window.player.x, y: window.player.y }; + const distance = Phaser.Math.Distance.Between( + npc.sprite.x, npc.sprite.y, + playerPos.x, playerPos.y + ); + + if (distance <= state.attackRange) { + // HIT + window.combatSounds?.playHit(); + window.combatSounds?.playPlayerHurt(); + + window.playerHealth.damagePlayer(state.attackDamage); + + // Strong feedback for player damage + window.screenEffects?.flashDamage(); + window.screenEffects?.shakeMedium(); + flashSprite(window.player, 0xff0000, 300); + + window.damageNumbers?.show( + window.player.x, + window.player.y - 30, + state.attackDamage, + false, + false + ); + } else { + // MISS + window.combatSounds?.playMiss(); + } + + // Update cooldown + state.lastAttackTime = Date.now(); +} +``` + +--- + +## Feedback Integration Checklist + +When integrating feedback systems: + +- [ ] Create damage numbers pool +- [ ] Create screen flash overlay +- [ ] Add screen shake support +- [ ] Create sprite flash functions +- [ ] Create attack telegraph graphics +- [ ] Load sound effects (or skip for MVP) +- [ ] Add feedback calls to player punch +- [ ] Add feedback calls to NPC attack +- [ ] Add feedback to damage functions +- [ ] Test all feedback types +- [ ] Add accessibility toggles for effects +- [ ] Verify performance impact acceptable + +## Accessibility Settings + +Add to game settings: + +```javascript +const feedbackSettings = { + screenFlash: true, + screenShake: true, + damageNumbers: true, + sounds: true, + attackTelegraphs: true +}; + +// Apply settings +COMBAT_CONFIG.feedback.enableScreenFlash = feedbackSettings.screenFlash; +COMBAT_CONFIG.feedback.enableScreenShake = feedbackSettings.screenShake; +COMBAT_CONFIG.feedback.enableDamageNumbers = feedbackSettings.damageNumbers; +COMBAT_CONFIG.feedback.enableSounds = feedbackSettings.sounds; + +// Settings UI +/* +Combat Feedback Settings +[ ] Screen Flash Effects +[ ] Screen Shake +[ ] Damage Numbers +[ ] Sound Effects +[ ] Attack Warnings +*/ +``` + +## Summary + +Enhanced feedback makes combat feel responsive and clear. Priority order: + +1. **Damage numbers** - Critical for understanding combat +2. **Screen flash** - Clear player damage feedback +3. **Sprite flash** - Visual hit confirmation +4. **Attack telegraph** - Fairness (player can react) +5. **Sound effects** - Polish (can be added later) +6. **Screen shake** - Polish (optional) + +Implement in this order for best ROI on development time. 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 `