mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
feat(npc): Implement conversation state management and enhance NPC interaction features
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
121
js/systems/npc-conversation-state.js
Normal file
121
js/systems/npc-conversation-state.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
server.py
43
server.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user