feat(npc): Implement conversation state management and enhance NPC interaction features

This commit is contained in:
Z. Cliffe Schreuders
2025-11-05 01:03:08 +00:00
parent d217e5a02a
commit 4daaa87534
12 changed files with 216 additions and 72 deletions

View File

@@ -107,7 +107,7 @@
<div class="popup-overlay"></div>
<!-- Main Game JavaScript Module -->
<script type="module" src="js/main.js?v=33"></script>
<script type="module" src="js/main.js?v=40"></script>
<!-- Mobile touch handling -->
<script>

View File

@@ -2,7 +2,7 @@ import { initializeRooms, calculateWorldBounds, calculateRoomPositions, createRo
import { createPlayer, updatePlayerMovement, movePlayerToPoint, player } from './player.js?v=7';
import { initializePathfinder } from './pathfinding.js?v=7';
import { initializeInventory, processInitialInventoryItems } from '../systems/inventory.js?v=8';
import { checkObjectInteractions, setGameInstance } from '../systems/interactions.js?v=23';
import { checkObjectInteractions, setGameInstance } from '../systems/interactions.js?v=25';
import { introduceScenario } from '../utils/helpers.js?v=19';
import '../minigames/index.js?v=2';
import SoundManager from '../systems/sound-manager.js?v=1';

View File

@@ -49,7 +49,7 @@ import { initializeDoors, createDoorSpritesForRoom, checkDoorTransitions, update
import { initializeObjectPhysics, setupChairCollisions, setupExistingChairsWithNewRoom, calculateChairSpinDirection, updateSwivelChairRotation, updateSpriteDepth } from '../systems/object-physics.js';
import { initializePlayerEffects, createPlayerBumpEffect, createPlantBumpEffect } from '../systems/player-effects.js';
import { initializeCollision, createWallCollisionBoxes, removeTilesUnderDoor, removeWallTilesForDoorInRoom, removeWallTilesAtWorldPosition } from '../systems/collision.js';
import NPCSpriteManager from '../systems/npc-sprites.js';
import NPCSpriteManager from '../systems/npc-sprites.js?v=3';
export let rooms = {};
export let currentRoom = '';

View File

@@ -1,5 +1,5 @@
import { GAME_CONFIG } from './utils/constants.js?v=8';
import { preload, create, update } from './core/game.js?v=32';
import { preload, create, update } from './core/game.js?v=39';
import { initializeNotifications } from './systems/notifications.js?v=7';
// Bluetooth scanner is now handled as a minigame
// Biometrics is now handled as a minigame

View File

@@ -10,7 +10,7 @@ export { BluetoothScannerMinigame, startBluetoothScannerMinigame } from './bluet
export { BiometricsMinigame, startBiometricsMinigame } from './biometrics/biometrics-minigame.js';
export { ContainerMinigame, startContainerMinigame, returnToContainerAfterNotes } from './container/container-minigame.js';
export { PhoneChatMinigame, returnToPhoneAfterNotes } from './phone-chat/phone-chat-minigame.js';
export { PersonChatMinigame } from './person-chat/person-chat-minigame.js';
export { PersonChatMinigame } from './person-chat/person-chat-minigame.js?v=7';
export { PinMinigame, startPinMinigame } from './pin/pin-minigame.js';
export { PasswordMinigame } from './password/password-minigame.js';
export { TextFileMinigame, returnToTextFileAfterNotes } from './text-file/text-file-minigame.js';
@@ -58,7 +58,7 @@ import { ContainerMinigame, startContainerMinigame, returnToContainerAfterNotes
import { PhoneChatMinigame, returnToPhoneAfterNotes } from './phone-chat/phone-chat-minigame.js';
// Import the person chat minigame (In-person NPC conversations)
import { PersonChatMinigame } from './person-chat/person-chat-minigame.js';
import { PersonChatMinigame } from './person-chat/person-chat-minigame.js?v=2';
// Import the PIN minigame
import { PinMinigame, startPinMinigame } from './pin/pin-minigame.js';

View File

@@ -17,6 +17,7 @@ import PersonChatUI from './person-chat-ui.js';
import PhoneChatConversation from '../phone-chat/phone-chat-conversation.js'; // Reuse phone-chat conversation logic
import InkEngine from '../../systems/ink/ink-engine.js?v=1';
import { processGameActionTags, determineSpeaker as determineSpeakerFromTags } from '../helpers/chat-helpers.js';
import npcConversationStateManager from '../../systems/npc-conversation-state.js?v=2';
export class PersonChatMinigame extends MinigameScene {
/**
@@ -200,9 +201,22 @@ export class PersonChatMinigame extends MinigameScene {
return;
}
// Navigate to start knot
const startKnot = this.npc.currentKnot || 'start';
this.conversation.goToKnot(startKnot);
// 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}`);
}
this.isConversationActive = true;
@@ -246,26 +260,35 @@ export class PersonChatMinigame extends MinigameScene {
console.log(`🗣️ showCurrentDialogue - this.ui exists:`, !!this.ui);
console.log(`🗣️ showCurrentDialogue - this.ui.showDialogue exists:`, typeof this.ui?.showDialogue);
// Display dialogue text with speaker (only if there's actual text)
if (result.text && result.text.trim()) {
console.log(`🗣️ Calling showDialogue with speaker: ${speaker}`);
this.ui.showDialogue(result.text, speaker);
} else {
console.log(`⚠️ Skipping showDialogue - no text or text is empty`);
}
// Display choices if available
// Display choices if available (check this first, before text)
if (result.choices && result.choices.length > 0) {
// At a choice point - display choices
this.ui.showChoices(result.choices);
console.log(`📋 ${result.choices.length} choices available`);
} else if (result.canContinue) {
// No choices but can continue - auto-advance after delay
console.log('⏳ Auto-continuing in 2 seconds...');
setTimeout(() => this.showCurrentDialogue(), 2000);
// Also display any accompanying text if present
if (result.text && result.text.trim()) {
console.log(`🗣️ Calling showDialogue with speaker: ${speaker}`);
this.ui.showDialogue(result.text, speaker);
}
} else if (result.text && result.text.trim()) {
// Have text but no choices - display and continue
console.log(`🗣️ Calling showDialogue with speaker: ${speaker}`);
this.ui.showDialogue(result.text, speaker);
if (result.canContinue) {
// Can continue - auto-advance after delay
console.log('⏳ Auto-continuing in 2 seconds...');
setTimeout(() => this.showCurrentDialogue(), 2000);
} else {
// Can't continue but have text - story will end
console.log('✓ Waiting for story to end...');
setTimeout(() => this.endConversation(), 1000);
}
} else {
// No choices and can't continue - story will end
console.log('✓ Waiting for story to end...');
setTimeout(() => this.endConversation(), 1000);
// No text and no choices - story has ended
console.log('🏁 No text and no choices - story ended');
this.endConversation();
}
} catch (error) {
console.error('❌ Error showing dialogue:', error);
@@ -369,6 +392,12 @@ export class PersonChatMinigame extends MinigameScene {
return;
}
// Process any game action tags (give_item, unlock_door, etc.) BEFORE displaying dialogue
if (result.tags && result.tags.length > 0) {
console.log('🏷️ Processing action tags from accumulated dialogue:', result.tags);
processGameActionTags(result.tags, this.ui);
}
// Split text into lines
const lines = result.text.split('\n').filter(line => line.trim());
@@ -561,6 +590,11 @@ export class PersonChatMinigame extends MinigameScene {
this.isConversationActive = false;
// Save the conversation state before ending
if (this.inkEngine && this.inkEngine.story) {
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
}
// Show completion message
if (this.ui.elements.dialogueText) {
this.ui.elements.dialogueText.textContent = 'Conversation ended.';
@@ -575,6 +609,21 @@ export class PersonChatMinigame extends MinigameScene {
}, 1000);
}
/**
* Override cleanup to ensure conversation state is saved
* This is called by the base class before the minigame is removed
*/
cleanup() {
// Save conversation state before cleanup
if (this.isConversationActive && this.inkEngine && this.inkEngine.story) {
console.log(`💾 Saving NPC state on cleanup for ${this.npcId}`);
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
}
// Call parent cleanup
super.cleanup();
}
/**
* Show error message
* @param {string} message - Error message to display

View File

@@ -28,7 +28,7 @@ export default class PersonChatPortraits {
// Portrait settings
this.portraitWidth = 200; // Portrait display size
this.portraitHeight = 250;
this.zoomLevel = 4; // 4x zoom on sprite
this.zoomLevel = 2; // 2x zoom on sprite
this.updateInterval = 100; // Update portrait every 100ms during conversation
// State
@@ -38,6 +38,7 @@ export default class PersonChatPortraits {
this.gameCanvas = null;
console.log(`🖼️ Portrait renderer created for NPC: ${npc.id}`);
alert('Portrait renderer created for NPC: ' + npc.id);
}
/**

View File

@@ -277,8 +277,8 @@ export function checkObjectInteractions() {
}
} 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);
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;
@@ -293,8 +293,8 @@ export function checkObjectInteractions() {
}
} 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);
const iconX = Math.round(sprite.x + 5);
const iconY = Math.round(sprite.y - 38);
sprite.interactionIndicator.setPosition(iconX, iconY);
}
});
@@ -363,13 +363,11 @@ function addInteractionIndicator(obj) {
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 talkIconX = Math.round(obj.x + 5); // Centered above
const talkIconY = Math.round(obj.y - 38); // 32 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

View File

@@ -0,0 +1,121 @@
/**
* NPC Conversation State Manager
*
* Persists NPC conversation state (Ink variables, choices, progress) across multiple conversations.
* Stores serialized story state so NPCs remember their relationships and story progression.
*
* @module npc-conversation-state
*/
class NPCConversationStateManager {
constructor() {
this.conversationStates = new Map(); // { npcId: { storyState, variables, ... } }
console.log('🗂️ NPC Conversation State Manager initialized');
}
/**
* Save the current state of an NPC's conversation
* @param {string} npcId - NPC identifier
* @param {Object} story - The Ink story object
*/
saveNPCState(npcId, story) {
if (!npcId || !story) return;
try {
// Serialize the story state (includes all variables and progress)
// Use uppercase ToJson as per inkjs API
const storyState = story.state.ToJson();
const state = {
storyState: storyState,
timestamp: Date.now()
};
this.conversationStates.set(npcId, state);
console.log(`💾 Saved conversation state for NPC: ${npcId}`, {
timestamp: new Date(state.timestamp).toLocaleTimeString()
});
} catch (error) {
console.error(`❌ Error saving NPC state for ${npcId}:`, error);
}
}
/**
* Restore the state of an NPC's conversation
* @param {string} npcId - NPC identifier
* @param {Object} story - The Ink story object to restore into
* @returns {boolean} True if state was restored
*/
restoreNPCState(npcId, story) {
if (!npcId || !story) return false;
const state = this.conversationStates.get(npcId);
if (!state) {
console.log(` No saved state for NPC: ${npcId} (first conversation)`);
return false;
}
try {
// Restore the serialized story state
// Use uppercase LoadJson as per inkjs API
story.state.LoadJson(state.storyState);
console.log(`✅ Restored conversation state for NPC: ${npcId}`, {
savedAt: new Date(state.timestamp).toLocaleTimeString()
});
return true;
} catch (error) {
console.error(`❌ Error restoring NPC state for ${npcId}:`, error);
return false;
}
}
/**
* Get the current state for an NPC (for debugging)
* @param {string} npcId - NPC identifier
* @returns {Object|null} Conversation state or null if not found
*/
getNPCState(npcId) {
return this.conversationStates.get(npcId) || null;
}
/**
* Clear the state for an NPC (useful for resetting conversations)
* @param {string} npcId - NPC identifier
*/
clearNPCState(npcId) {
if (this.conversationStates.has(npcId)) {
this.conversationStates.delete(npcId);
console.log(`🗑️ Cleared conversation state for NPC: ${npcId}`);
}
}
/**
* Clear all NPC states (useful for scenario reset)
*/
clearAllStates() {
const count = this.conversationStates.size;
this.conversationStates.clear();
console.log(`🗑️ Cleared all NPC conversation states (${count} NPCs)`);
}
/**
* Get list of NPCs with saved state
* @returns {Array<string>} Array of NPC IDs with persistent state
*/
getSavedNPCs() {
return Array.from(this.conversationStates.keys());
}
}
// Create global instance
const npcConversationStateManager = new NPCConversationStateManager();
// Export for use in modules
export default npcConversationStateManager;
// Also attach to window for global access
if (typeof window !== 'undefined') {
window.npcConversationStateManager = npcConversationStateManager;
}

View File

@@ -49,9 +49,9 @@ export function createNPCSprite(scene, npc, roomData) {
// Enable physics
scene.physics.add.existing(sprite);
sprite.body.immovable = true; // NPCs don't move on collision
// Set smaller collision box at the feet (matching player collision: 15x10 with similar offset)
sprite.body.setSize(15, 10); // Collision body size (matches player)
sprite.body.setOffset(25, 50); // Offset for feet position (64px sprite, same as player)
// Set smaller collision box at the feet (matching player collision: 18x10 with similar offset)
sprite.body.setSize(18, 10); // Collision body size (wider for better hit detection)
sprite.body.setOffset(23, 50); // Offset for feet position (64px sprite, adjusted for wider box)
// Set up animations
setupNPCAnimations(scene, sprite, spriteSheet, config, npc.id);

View File

@@ -12,7 +12,7 @@ export class NPCTalkIconSystem {
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: 0 };
this.ICON_OFFSET = { x: 0, y: -33 };
this.INTERACTION_RANGE = 64; // Pixels
this.UPDATE_INTERVAL = 200; // ms between updates
this.lastUpdate = 0;

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env python3
"""
HTTP Server with proper cache headers for development
- JSON files: No cache (always fresh)
- JS/CSS: Short cache (1 hour)
- Static assets: Longer cache (1 day)
HTTP Server with zero caching for development
- ALL files: No cache (always fresh)
"""
import http.server
@@ -17,36 +15,17 @@ PORT = 8000
class NoCacheHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
now = datetime.utcnow()
# Get the file path
file_path = self.translate_path(self.path)
# Set cache headers based on file type
if self.path.endswith('.json'):
# JSON files: Always fresh (no cache)
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
# IMPORTANT: Override Last-Modified BEFORE calling parent end_headers()
self.send_header('Last-Modified', formatdate(timeval=None, localtime=False, usegmt=True))
elif self.path.endswith(('.js', '.css')):
# JS/CSS: Cache for 1 hour (development)
self.send_header('Cache-Control', 'public, max-age=3600')
elif self.path.endswith(('.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp')):
# Images: Cache for 1 day
self.send_header('Cache-Control', 'public, max-age=86400')
else:
# HTML and other files: No cache
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
# Disable all caching for ALL file types
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0, private')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Last-Modified', formatdate(timeval=None, localtime=False, usegmt=True))
# Add CORS headers for local development
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
# Call parent to add any remaining headers (this will NOT override ours)
# Call parent to add any remaining headers
super().end_headers()
if __name__ == '__main__':
@@ -54,11 +33,7 @@ if __name__ == '__main__':
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"🚀 Development Server running at http://localhost:{PORT}/")
print(f"📄 Cache policy:")
print(f" - JSON files: No cache (always fresh)")
print(f" - JS/CSS: 1 hour cache")
print(f" - Images: 1 day cache")
print(f" - Other: No cache")
print(f"📄 Cache policy: ZERO CACHING - all files always fresh")
print(f"\n⌨️ Press Ctrl+C to stop")
try:
httpd.serve_forever()