/** * DialogueSystem.js * ================= * KRVAVA Ε½ETEV - NPC Dialogue & Conversation System * * Features: * - Dynamic dialogue trees with choices * - Character portraits and emotions * - Quest integration (dialogue can trigger/complete quests) * - Relationship tracking (affects dialogue options) * - Twin Bond special dialogues (Ana's voice) * - Memory system (NPCs remember past conversations) * * @author NovaFarma Team * @date 2025-12-23 */ export default class DialogueSystem { constructor(scene) { this.scene = scene; // Active dialogue state this.currentDialogue = null; this.currentSpeaker = null; this.currentNode = null; this.dialogueHistory = new Map(); // NPC ID -> conversation history // UI elements this.dialogueBox = null; this.portraitSprite = null; this.nameText = null; this.dialogueText = null; this.choicesContainer = null; // Dialogue database this.dialogues = new Map(); // Dialogue ID -> dialogue data // Callbacks this.onDialogueComplete = null; this.onChoiceMade = null; console.log('πŸ’¬ DialogueSystem initialized'); } /** * Register a dialogue tree * @param {string} dialogueId - Unique identifier * @param {object} dialogueData - Tree structure */ registerDialogue(dialogueId, dialogueData) { this.dialogues.set(dialogueId, dialogueData); console.log(`πŸ’¬ Registered dialogue: ${dialogueId}`); } /** * Start a dialogue with an NPC * @param {string} dialogueId - Which dialogue to start * @param {object} speaker - NPC or character data * @param {function} onComplete - Callback when done */ startDialogue(dialogueId, speaker, onComplete) { const dialogueData = this.dialogues.get(dialogueId); if (!dialogueData) { console.error(`Dialogue not found: ${dialogueId}`); return; } this.currentDialogue = dialogueData; this.currentSpeaker = speaker; this.onDialogueComplete = onComplete; // Show UI this.createDialogueUI(); // Start at root node this.showNode(dialogueData.root || 'start'); // Pause game this.scene.physics.pause(); console.log(`πŸ’¬ Started dialogue: ${dialogueId} with ${speaker.name}`); } /** * Show a specific dialogue node * @param {string} nodeId - Node to display */ showNode(nodeId) { const node = this.currentDialogue.nodes[nodeId]; if (!node) { console.error(`Node not found: ${nodeId}`); this.endDialogue(); return; } this.currentNode = node; // Update speaker info const speaker = node.speaker || this.currentSpeaker.name; const emotion = node.emotion || 'neutral'; this.updateSpeaker(speaker, emotion); // Show text with typewriter effect this.typewriterText(node.text); // Show choices or continue button if (node.choices && node.choices.length > 0) { this.showChoices(node.choices); } else if (node.next) { this.showContinueButton(node.next); } else { // End of dialogue this.showContinueButton('END'); } // Execute node actions if (node.action) { this.executeNodeAction(node.action); } // Record in history this.addToHistory(nodeId, node.text); } /** * Create dialogue UI */ createDialogueUI() { const width = this.scene.cameras.main.width; const height = this.scene.cameras.main.height; // Container for everything this.dialogueContainer = this.scene.add.container(0, 0); this.dialogueContainer.setDepth(1000); // Dark overlay const overlay = this.scene.add.rectangle(0, 0, width, height, 0x000000, 0.5); overlay.setOrigin(0); overlay.setInteractive(); this.dialogueContainer.add(overlay); // Dialogue box const boxY = height - 200; const boxHeight = 180; this.dialogueBox = this.scene.add.rectangle( width / 2, boxY, width - 80, boxHeight, 0x2d1b00, 0.95 ); this.dialogueBox.setStrokeStyle(3, 0xd4a574); this.dialogueContainer.add(this.dialogueBox); // Portrait background this.portraitBg = this.scene.add.rectangle(100, boxY, 100, 100, 0x4a3520, 0.9); this.portraitBg.setStrokeStyle(2, 0xd4a574); this.dialogueContainer.add(this.portraitBg); // Portrait this.portraitSprite = this.scene.add.text(100, boxY, 'πŸ‘€', { fontSize: '64px' }); this.portraitSprite.setOrigin(0.5); this.dialogueContainer.add(this.portraitSprite); // Speaker name this.nameText = this.scene.add.text(170, boxY - 70, '', { fontSize: '20px', fontFamily: 'Georgia, serif', color: '#FFD700', fontStyle: 'bold', stroke: '#000000', strokeThickness: 3 }); this.dialogueContainer.add(this.nameText); // Dialogue text this.dialogueText = this.scene.add.text(170, boxY - 40, '', { fontSize: '18px', fontFamily: 'Georgia, serif', color: '#f4e4c1', wordWrap: { width: width - 280 }, lineSpacing: 6 }); this.dialogueContainer.add(this.dialogueText); // Choices container this.choicesContainer = this.scene.add.container(width / 2, boxY + 120); this.dialogueContainer.add(this.choicesContainer); } /** * Update speaker display */ updateSpeaker(name, emotion) { this.nameText.setText(name); // Get portrait based on speaker and emotion const portrait = this.getPortrait(name, emotion); this.portraitSprite.setText(portrait); } /** * Get portrait emoji */ getPortrait(speaker, emotion) { // Kai portraits if (speaker === 'Kai' || speaker === 'You') { const kaiEmotions = { 'neutral': 'πŸ‘¨', 'happy': '😊', 'sad': '😒', 'angry': '😠', 'worried': '😟', 'shocked': '😱', 'determined': '😀' }; return kaiEmotions[emotion] || 'πŸ‘¨'; } // Ana portraits (Twin Bond - ghostly) if (speaker === 'Ana' || speaker === 'Ana (Twin Bond)') { const anaEmotions = { 'neutral': 'πŸ‘»', 'happy': 'πŸ˜‡', 'sad': '😰', 'worried': '😨', 'pain': '😣', 'help': 'πŸ†˜' }; return anaEmotions[emotion] || 'πŸ‘»'; } // Other NPCs const npcPortraits = { 'Grok': '🧘', 'Elder': 'πŸ‘΄', 'Blacksmith': 'πŸ”¨', 'Baker': '🍞', 'Doctor': 'βš•οΈ', 'Merchant': 'πŸ’°', 'Stranger': '❓', 'Zombie': '🧟' }; return npcPortraits[speaker] || 'πŸ‘€'; } /** * Typewriter text effect */ typewriterText(text) { let displayText = ''; let charIndex = 0; this.dialogueText.setText(''); const timer = this.scene.time.addEvent({ delay: 30, callback: () => { if (charIndex < text.length) { displayText += text[charIndex]; this.dialogueText.setText(displayText); charIndex++; } else { timer.remove(); } }, loop: true }); } /** * Show dialogue choices */ showChoices(choices) { this.choicesContainer.removeAll(true); choices.forEach((choice, index) => { // Check if choice is available if (choice.condition && !this.checkCondition(choice.condition)) { return; // Skip this choice } const y = index * 50; // Choice button background const btn = this.scene.add.rectangle(0, y, 600, 40, 0x6b4423, 1); btn.setStrokeStyle(2, 0xd4a574); btn.setInteractive({ useHandCursor: true }); // Choice text const text = this.scene.add.text(0, y, choice.text, { fontSize: '16px', fontFamily: 'Georgia, serif', color: '#f4e4c1', fontStyle: 'bold' }); text.setOrigin(0.5); // Hover effects btn.on('pointerover', () => { btn.setFillStyle(0x8b5a3c); text.setColor('#FFD700'); }); btn.on('pointerout', () => { btn.setFillStyle(0x6b4423); text.setColor('#f4e4c1'); }); // Click handler btn.on('pointerdown', () => { this.onChoiceSelected(choice); }); this.choicesContainer.add([btn, text]); }); } /** * Show continue button */ showContinueButton(nextNode) { this.choicesContainer.removeAll(true); const continueBtn = this.scene.add.text(0, 0, 'β–Ό Continue (SPACE)', { fontSize: '16px', fontFamily: 'Georgia, serif', color: '#888888' }); continueBtn.setOrigin(0.5); // Pulse animation this.scene.tweens.add({ targets: continueBtn, alpha: 0.3, duration: 800, yoyo: true, repeat: -1 }); this.choicesContainer.add(continueBtn); // Space key or click to continue const spaceKey = this.scene.input.keyboard.addKey('SPACE'); spaceKey.once('down', () => { if (nextNode === 'END') { this.endDialogue(); } else { this.showNode(nextNode); } }); } /** * Handle choice selection */ onChoiceSelected(choice) { console.log(`πŸ’¬ Choice selected: ${choice.text}`); // Execute choice action if (choice.action) { this.executeNodeAction(choice.action); } // Callback if (this.onChoiceMade) { this.onChoiceMade(choice); } // Go to next node if (choice.next) { this.showNode(choice.next); } else { this.endDialogue(); } } /** * Execute node action */ executeNodeAction(action) { switch (action.type) { case 'quest_start': this.scene.questSystem?.startQuest(action.questId); break; case 'quest_complete': this.scene.questSystem?.completeQuest(action.questId); break; case 'give_item': this.scene.inventorySystem?.addItem(action.itemId, action.amount); break; case 'take_item': this.scene.inventorySystem?.removeItem(action.itemId, action.amount); break; case 'relationship_change': this.changeRelationship(action.npcId, action.amount); break; case 'custom': if (action.callback) { action.callback(this.scene); } break; } } /** * Check if condition is met */ checkCondition(condition) { switch (condition.type) { case 'quest_active': return this.scene.questSystem?.isQuestActive(condition.questId); case 'quest_complete': return this.scene.questSystem?.isQuestComplete(condition.questId); case 'has_item': return this.scene.inventorySystem?.hasItem(condition.itemId, condition.amount); case 'relationship': return this.getRelationship(condition.npcId) >= condition.value; case 'custom': return condition.check(this.scene); default: return true; } } /** * Add to conversation history */ addToHistory(nodeId, text) { const speakerId = this.currentSpeaker.id || this.currentSpeaker.name; if (!this.dialogueHistory.has(speakerId)) { this.dialogueHistory.set(speakerId, []); } this.dialogueHistory.get(speakerId).push({ nodeId: nodeId, text: text, timestamp: Date.now() }); } /** * Relationship tracking */ changeRelationship(npcId, amount) { // TODO: Integrate with proper relationship system console.log(`πŸ’• Relationship with ${npcId}: ${amount > 0 ? '+' : ''}${amount}`); } getRelationship(npcId) { // TODO: Get from relationship system return 0; } /** * End dialogue */ endDialogue() { console.log('πŸ’¬ Dialogue ended'); // Clean up UI if (this.dialogueContainer) { this.dialogueContainer.destroy(); } // Resume game this.scene.physics.resume(); // Callback if (this.onDialogueComplete) { this.onDialogueComplete(); } // Reset state this.currentDialogue = null; this.currentSpeaker = null; this.currentNode = null; this.onDialogueComplete = null; } /** * Check if dialogue is active */ isActive() { return this.currentDialogue !== null; } }