feat(npc): Add talk icon system and update NPC sprite configurations

This commit is contained in:
Z. Cliffe Schreuders
2025-11-04 21:31:00 +00:00
parent 9f82abd072
commit e0cef60f37
9 changed files with 361 additions and 63 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<string>} 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<string>} 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';
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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