diff --git a/assets/characters/hacker-red-talk.png b/assets/characters/hacker-red-talk.png new file mode 100644 index 0000000..d945da1 Binary files /dev/null and b/assets/characters/hacker-red-talk.png differ diff --git a/assets/characters/hacker-red.png b/assets/characters/hacker-red.png new file mode 100644 index 0000000..0fb2c3f Binary files /dev/null and b/assets/characters/hacker-red.png differ diff --git a/js/core/game.js b/js/core/game.js index 1cf59fd..3f4655d 100644 --- a/js/core/game.js +++ b/js/core/game.js @@ -67,6 +67,7 @@ export function preload() { this.load.image('keyway', 'assets/icons/keyway.png'); this.load.image('password', 'assets/icons/password.png'); this.load.image('pin', 'assets/icons/pin.png'); + this.load.image('talk', 'assets/icons/talk.png'); // Load new object sprites from Tiled map tileset // These are the key objects that appear in the new room_reception2.json @@ -357,41 +358,47 @@ export function preload() { this.load.image('torch-1', 'assets/objects/torch-1.png'); // Load character sprite sheet instead of single image - this.load.spritesheet('hacker', 'assets/characters/hacker.png', { - frameWidth: 64, - frameHeight: 64 - }); - - // Animated plant textures are loaded above - - // Load swivel chair rotation images - this.load.image('chair-exec-rotate1', 'assets/objects/chair-exec-rotate1.png'); - this.load.image('chair-exec-rotate2', 'assets/objects/chair-exec-rotate2.png'); - this.load.image('chair-exec-rotate3', 'assets/objects/chair-exec-rotate3.png'); - this.load.image('chair-exec-rotate4', 'assets/objects/chair-exec-rotate4.png'); - this.load.image('chair-exec-rotate5', 'assets/objects/chair-exec-rotate5.png'); - this.load.image('chair-exec-rotate6', 'assets/objects/chair-exec-rotate6.png'); - this.load.image('chair-exec-rotate7', 'assets/objects/chair-exec-rotate7.png'); - this.load.image('chair-exec-rotate8', 'assets/objects/chair-exec-rotate8.png'); - - // Load white chair rotation images - this.load.image('chair-white-1-rotate1', 'assets/objects/chair-white-1-rotate1.png'); - this.load.image('chair-white-1-rotate2', 'assets/objects/chair-white-1-rotate2.png'); - this.load.image('chair-white-1-rotate3', 'assets/objects/chair-white-1-rotate3.png'); - this.load.image('chair-white-1-rotate4', 'assets/objects/chair-white-1-rotate4.png'); - this.load.image('chair-white-1-rotate5', 'assets/objects/chair-white-1-rotate5.png'); - this.load.image('chair-white-1-rotate6', 'assets/objects/chair-white-1-rotate6.png'); - this.load.image('chair-white-1-rotate7', 'assets/objects/chair-white-1-rotate7.png'); - this.load.image('chair-white-1-rotate8', 'assets/objects/chair-white-1-rotate8.png'); - - this.load.image('chair-white-2-rotate1', 'assets/objects/chair-white-2-rotate1.png'); - this.load.image('chair-white-2-rotate2', 'assets/objects/chair-white-2-rotate2.png'); - this.load.image('chair-white-2-rotate3', 'assets/objects/chair-white-2-rotate3.png'); - this.load.image('chair-white-2-rotate4', 'assets/objects/chair-white-2-rotate4.png'); - this.load.image('chair-white-2-rotate5', 'assets/objects/chair-white-2-rotate5.png'); - this.load.image('chair-white-2-rotate6', 'assets/objects/chair-white-2-rotate6.png'); - this.load.image('chair-white-2-rotate7', 'assets/objects/chair-white-2-rotate7.png'); - this.load.image('chair-white-2-rotate8', 'assets/objects/chair-white-2-rotate8.png'); + this.load.spritesheet('hacker', 'assets/characters/hacker.png', { + frameWidth: 64, + frameHeight: 64 + }); + + // Load character sprite sheet instead of single image + this.load.spritesheet('hacker-red', 'assets/characters/hacker-red.png', { + frameWidth: 64, + frameHeight: 64 + }); + + // Animated plant textures are loaded above + + // Load swivel chair rotation images + this.load.image('chair-exec-rotate1', 'assets/objects/chair-exec-rotate1.png'); + this.load.image('chair-exec-rotate2', 'assets/objects/chair-exec-rotate2.png'); + this.load.image('chair-exec-rotate3', 'assets/objects/chair-exec-rotate3.png'); + this.load.image('chair-exec-rotate4', 'assets/objects/chair-exec-rotate4.png'); + this.load.image('chair-exec-rotate5', 'assets/objects/chair-exec-rotate5.png'); + this.load.image('chair-exec-rotate6', 'assets/objects/chair-exec-rotate6.png'); + this.load.image('chair-exec-rotate7', 'assets/objects/chair-exec-rotate7.png'); + this.load.image('chair-exec-rotate8', 'assets/objects/chair-exec-rotate8.png'); + + // Load white chair rotation images + this.load.image('chair-white-1-rotate1', 'assets/objects/chair-white-1-rotate1.png'); + this.load.image('chair-white-1-rotate2', 'assets/objects/chair-white-1-rotate2.png'); + this.load.image('chair-white-1-rotate3', 'assets/objects/chair-white-1-rotate3.png'); + this.load.image('chair-white-1-rotate4', 'assets/objects/chair-white-1-rotate4.png'); + this.load.image('chair-white-1-rotate5', 'assets/objects/chair-white-1-rotate5.png'); + this.load.image('chair-white-1-rotate6', 'assets/objects/chair-white-1-rotate6.png'); + this.load.image('chair-white-1-rotate7', 'assets/objects/chair-white-1-rotate7.png'); + this.load.image('chair-white-1-rotate8', 'assets/objects/chair-white-1-rotate8.png'); + + this.load.image('chair-white-2-rotate1', 'assets/objects/chair-white-2-rotate1.png'); + this.load.image('chair-white-2-rotate2', 'assets/objects/chair-white-2-rotate2.png'); + this.load.image('chair-white-2-rotate3', 'assets/objects/chair-white-2-rotate3.png'); + this.load.image('chair-white-2-rotate4', 'assets/objects/chair-white-2-rotate4.png'); + this.load.image('chair-white-2-rotate5', 'assets/objects/chair-white-2-rotate5.png'); + this.load.image('chair-white-2-rotate6', 'assets/objects/chair-white-2-rotate6.png'); + this.load.image('chair-white-2-rotate7', 'assets/objects/chair-white-2-rotate7.png'); + this.load.image('chair-white-2-rotate8', 'assets/objects/chair-white-2-rotate8.png'); // Load audio files // NPC system sounds diff --git a/js/core/rooms.js b/js/core/rooms.js index be0bafd..8760171 100644 --- a/js/core/rooms.js +++ b/js/core/rooms.js @@ -1742,6 +1742,11 @@ export function updatePlayerRoom() { discoveredRooms.add(doorTransitionRoom); window.discoveredRooms = discoveredRooms; console.log(`✅ Marked room ${doorTransitionRoom} as discovered`); + + // Update NPC talk icons for the new room + if (window.npcTalkIcons && rooms[doorTransitionRoom].npcSprites) { + window.npcTalkIcons.init([], rooms[doorTransitionRoom].npcSprites); + } } if (previousRoom) { diff --git a/js/minigames/helpers/chat-helpers.js b/js/minigames/helpers/chat-helpers.js index 0c0e995..04a813c 100644 --- a/js/minigames/helpers/chat-helpers.js +++ b/js/minigames/helpers/chat-helpers.js @@ -11,6 +11,7 @@ /** * Process game action tags from Ink story * Tags format: # unlock_door:ceo, # give_item:keycard|CEO Keycard, etc. + * Filters out speaker tags (player, npc, speaker:player, speaker:npc) * * @param {Array} tags - Array of tag strings from Ink story * @param {Object} ui - UI controller with showNotification method @@ -26,11 +27,25 @@ export function processGameActionTags(tags, ui) { return []; } - console.log('🏷️ Processing game action tags:', tags); + // Filter out speaker tags - only process action tags + const actionTags = tags.filter(tag => { + const action = tag.split(':')[0].trim().toLowerCase(); + return action !== 'player' && + action !== 'npc' && + action !== 'speaker' && + !tag.includes('speaker:'); + }); + + if (actionTags.length === 0) { + // No action tags to process (all were speaker tags) + return []; + } + + console.log('🏷️ Processing game action tags:', actionTags); const results = []; - tags.forEach(tag => { + actionTags.forEach(tag => { const trimmedTag = tag.trim(); // Skip empty tags @@ -177,15 +192,18 @@ export function getActionTags(tags) { /** * Determine speaker from tags + * Finds the LAST speaker tag (most recent/current speaker) + * * @param {Array} tags - Tags from story * @param {string} defaultSpeaker - Default speaker if not found in tags * @returns {string} Speaker ('npc' or 'player') */ export function determineSpeaker(tags, defaultSpeaker = 'npc') { - if (!tags) return defaultSpeaker; + if (!tags || tags.length === 0) return defaultSpeaker; - for (const tag of tags) { - const trimmed = tag.trim().toLowerCase(); + // Check tags in REVERSE order to find the last speaker tag (current speaker) + for (let i = tags.length - 1; i >= 0; i--) { + const trimmed = tags[i].trim().toLowerCase(); if (trimmed === 'player' || trimmed === 'speaker:player') { return 'player'; } diff --git a/js/minigames/person-chat/person-chat-minigame.js b/js/minigames/person-chat/person-chat-minigame.js index 3213a73..bc5f768 100644 --- a/js/minigames/person-chat/person-chat-minigame.js +++ b/js/minigames/person-chat/person-chat-minigame.js @@ -229,20 +229,8 @@ export class PersonChatMinigame extends MinigameScene { * @returns {string} Speaker ('npc' or 'player') */ determineSpeaker(result) { - // Check for speaker tag in result - if (result.tags) { - for (const tag of result.tags) { - if (tag === 'player' || tag === 'speaker:player') { - return 'player'; - } - if (tag === 'npc' || tag === 'speaker:npc') { - return 'npc'; - } - } - } - - // Default: alternate speakers, or start with NPC - return this.currentSpeaker === 'player' ? 'npc' : 'npc'; + // Use the shared helper function from chat-helpers + return determineSpeakerFromTags(result.tags, 'npc'); } /** @@ -268,7 +256,40 @@ export class PersonChatMinigame extends MinigameScene { // Then display the result (NPC response) after a small delay setTimeout(() => { - this.displayDialogueResult(result); + // Extract NPC-only content from the accumulated result + // Split by speaker tag to separate player and NPC dialogue + let npcText = ''; + let npcTags = []; + + // Find the section with speaker:npc tag + if (result.tags && result.tags.includes('speaker:npc')) { + // Split the text by lines and reconstruct based on tags + const lines = result.text.split('\n').filter(line => line.trim()); + const tagIndex = result.tags.indexOf('speaker:npc'); + + // Get number of player lines before NPC (based on speaker:player tag position) + const playerTagIndex = result.tags.indexOf('speaker:player'); + let playerLineCount = playerTagIndex >= 0 ? 1 : 0; + + // Skip player lines and collect NPC lines + npcText = lines.slice(playerLineCount).join('\n').trim(); + npcTags = result.tags.filter((tag, idx) => idx >= tagIndex); + + console.log(`📄 Extracted NPC text: "${npcText.substring(0, 50)}..."`); + } else { + // Fallback: use full result if no speaker tag + npcText = result.text; + npcTags = result.tags; + } + + // Create a new result with only the NPC's dialogue + const npcOnlyResult = { + ...result, + text: npcText, + tags: npcTags + }; + + this.displayDialogueResult(npcOnlyResult); }, 1500); } catch (error) { console.error('❌ Error handling choice:', error); diff --git a/js/systems/interactions.js b/js/systems/interactions.js index 28a9cbf..34e3a03 100644 --- a/js/systems/interactions.js +++ b/js/systems/interactions.js @@ -266,18 +266,36 @@ export function checkObjectInteractions() { if (distanceSq <= INTERACTION_RANGE_SQ) { if (!sprite.isHighlighted) { sprite.isHighlighted = true; - sprite.setTint(0x4da6ff); // Blue tint for interactable NPCs - // Add interaction indicator sprite - addInteractionIndicator(sprite); + // Add talk icon indicator for NPC (created on first highlight) + if (!sprite.interactionIndicator) { + addInteractionIndicator(sprite); + } + // Show talk icon and don't apply tint - icon provides visual feedback + if (sprite.interactionIndicator) { + sprite.interactionIndicator.setVisible(true); + sprite.talkIconVisible = true; + } + } else if (sprite.interactionIndicator && !sprite.talkIconVisible) { + // Update position of talk icon to stay pixel-perfect on NPC + const iconX = Math.round(sprite.x + 0); + const iconY = Math.round(sprite.y - 48); + sprite.interactionIndicator.setPosition(iconX, iconY); + sprite.interactionIndicator.setVisible(true); + sprite.talkIconVisible = true; } } else if (sprite.isHighlighted) { sprite.isHighlighted = false; sprite.clearTint(); - // Clean up interaction sprite if exists + // Hide talk icon when out of range if (sprite.interactionIndicator) { - sprite.interactionIndicator.destroy(); - delete sprite.interactionIndicator; + sprite.interactionIndicator.setVisible(false); + sprite.talkIconVisible = false; } + } else if (sprite.interactionIndicator && sprite.talkIconVisible) { + // Update position even when not highlighted (for smooth following) + const iconX = Math.round(sprite.x + 0); + const iconY = Math.round(sprite.y - 48); + sprite.interactionIndicator.setPosition(iconX, iconY); } }); } @@ -341,6 +359,29 @@ function addInteractionIndicator(obj) { return; } + // NPCs get the talk icon above their heads with pixel-perfect positioning + if (obj._isNPC) { + try { + // Talk icon positioned above NPC with pixel-perfect coordinates + const talkIconX = Math.round(obj.x + 0); // Centered above + const talkIconY = Math.round(obj.y - 48); // 48 pixels above + + const indicator = obj.scene.add.image(talkIconX, talkIconY, 'talk'); + indicator.setDepth(obj.depth + 1); + indicator.setOrigin(0.5, 0.5); + indicator.setScale(0.75); // Slightly smaller than full size + indicator.setVisible(false); // Hidden until player is in range + + // Store reference for cleanup and visibility management + obj.interactionIndicator = indicator; + obj.talkIconVisible = false; + } catch (error) { + console.warn('Failed to add talk icon for NPC:', error); + } + return; + } + + // Non-NPC objects use the standard interaction indicator sprite const spriteKey = getInteractionSpriteKey(obj); if (!spriteKey) return; diff --git a/js/systems/npc-talk-icons.js b/js/systems/npc-talk-icons.js new file mode 100644 index 0000000..df74179 --- /dev/null +++ b/js/systems/npc-talk-icons.js @@ -0,0 +1,206 @@ +/** + * NPC Talk Icon System + * + * Displays a "talk" icon above NPC heads when the player is within interaction range. + * Manages icon creation, positioning, and visibility based on player proximity. + * + * @module npc-talk-icons + */ + +export class NPCTalkIconSystem { + constructor(scene) { + this.scene = scene; + this.npcIcons = new Map(); // { npcId: { npc, icon, sprite } } + // Offset from NPC position - use whole pixels to avoid sub-pixel rendering + this.ICON_OFFSET = { x: 0, y: -48 }; + this.INTERACTION_RANGE = 64; // Pixels + this.UPDATE_INTERVAL = 200; // ms between updates + this.lastUpdate = 0; + } + + /** + * Initialize talk icons for all NPCs in the current room + * @param {Array} npcs - Array of NPC objects + * @param {Array} sprites - Array of NPC sprite objects + */ + init(npcs, sprites) { + this.npcs = npcs || []; + this.sprites = sprites || []; + + // Create icons for each NPC sprite + if (this.sprites && Array.isArray(this.sprites)) { + this.sprites.forEach(spriteObj => { + this.createIconForNPC(spriteObj); + }); + } + + console.log(`💬 Initialized ${this.npcIcons.size} talk icons`); + } + + /** + * Create a talk icon for an NPC sprite + * @param {Object} spriteObj - NPC sprite object + */ + createIconForNPC(spriteObj) { + if (!spriteObj || !spriteObj.npcId) return; + + // Don't create duplicate icons + if (this.npcIcons.has(spriteObj.npcId)) return; + + try { + // Calculate pixel-perfect position (round to avoid sub-pixel rendering) + const iconX = Math.round(spriteObj.x + this.ICON_OFFSET.x); + const iconY = Math.round(spriteObj.y + this.ICON_OFFSET.y); + + // Create the icon image + const icon = this.scene.add.image(iconX, iconY, 'talk'); + + // Hide by default + icon.setVisible(false); + icon.setDepth(spriteObj.depth + 1); + icon.setScale(0.75); // Slightly smaller than full size + // Disable antialiasing to keep pixels sharp + icon.setOrigin(0.5, 0.5); + + // Store reference + this.npcIcons.set(spriteObj.npcId, { + npc: spriteObj, + icon: icon, + visible: false + }); + + console.log(`💬 Created talk icon for NPC: ${spriteObj.npcId}`); + } catch (error) { + console.error(`❌ Error creating talk icon for ${spriteObj.npcId}:`, error); + } + } + + /** + * Update icon visibility based on player proximity + * @param {Object} player - Player sprite object + */ + update(player) { + if (!player) return; + + // Throttle updates + const now = Date.now(); + if (now - this.lastUpdate < this.UPDATE_INTERVAL) { + return; + } + this.lastUpdate = now; + + // Check distance to each NPC + this.npcIcons.forEach((iconData, npcId) => { + const distance = Phaser.Math.Distance.Between( + player.x, + player.y, + iconData.npc.x, + iconData.npc.y + ); + + const shouldShow = distance <= this.INTERACTION_RANGE; + + // Update icon visibility and position + if (shouldShow !== iconData.visible) { + iconData.icon.setVisible(shouldShow); + iconData.visible = shouldShow; + } + + // Update position to follow NPC with pixel-perfect alignment + // Round to whole pixels to avoid sub-pixel rendering + const newX = Math.round(iconData.npc.x + this.ICON_OFFSET.x); + const newY = Math.round(iconData.npc.y + this.ICON_OFFSET.y); + iconData.icon.setPosition(newX, newY); + + // Update depth if needed + const expectedDepth = iconData.npc.depth + 1; + if (iconData.icon.depth !== expectedDepth) { + iconData.icon.setDepth(expectedDepth); + } + }); + } + + /** + * Show icon for a specific NPC + * @param {string} npcId - NPC ID + */ + showIcon(npcId) { + const iconData = this.npcIcons.get(npcId); + if (iconData && !iconData.visible) { + iconData.icon.setVisible(true); + iconData.visible = true; + } + } + + /** + * Hide icon for a specific NPC + * @param {string} npcId - NPC ID + */ + hideIcon(npcId) { + const iconData = this.npcIcons.get(npcId); + if (iconData && iconData.visible) { + iconData.icon.setVisible(false); + iconData.visible = false; + } + } + + /** + * Hide all talk icons + */ + hideAll() { + this.npcIcons.forEach(iconData => { + iconData.icon.setVisible(false); + iconData.visible = false; + }); + } + + /** + * Show all talk icons + */ + showAll() { + this.npcIcons.forEach(iconData => { + iconData.icon.setVisible(true); + iconData.visible = true; + }); + } + + /** + * Remove talk icon for a specific NPC + * @param {string} npcId - NPC ID + */ + removeIcon(npcId) { + const iconData = this.npcIcons.get(npcId); + if (iconData) { + iconData.icon.destroy(); + this.npcIcons.delete(npcId); + } + } + + /** + * Cleanup all icons + */ + destroy() { + this.npcIcons.forEach(iconData => { + iconData.icon.destroy(); + }); + this.npcIcons.clear(); + } + + /** + * Set interaction range for showing icons + * @param {number} range - Range in pixels + */ + setInteractionRange(range) { + this.INTERACTION_RANGE = range; + } + + /** + * Set icon offset from NPC position + * @param {Object} offset - {x, y} offset + */ + setIconOffset(offset) { + this.ICON_OFFSET = offset; + } +} + +export default NPCTalkIconSystem; diff --git a/scenarios/npc-sprite-test.json b/scenarios/npc-sprite-test.json index ad1b19d..53e4245 100644 --- a/scenarios/npc-sprite-test.json +++ b/scenarios/npc-sprite-test.json @@ -22,8 +22,8 @@ "npcType": "person", "roomId": "test_room", "position": { "x": 5, "y": 3 }, - "spriteSheet": "hacker", - "spriteTalk": "assets/characters/hacker-talk.png", + "spriteSheet": "hacker-red", + "spriteTalk": "assets/characters/hacker-red-talk.png", "spriteConfig": { "idleFrameStart": 20, "idleFrameEnd": 23