UNPKG

@robingamedev/visual-novel-dialogue

Version:

A minimal, JSON-driven dialogue plugin for Phaser 3 games with support for branching choices, typewriter effects, and simple inline formatting.

444 lines 14.6 kB
import { DialogueBox } from './DialogueBox'; import { ChoiceBox } from './ChoiceBox'; // Constants for default values and magic numbers const DEFAULT_FONT_FAMILY = 'Arial'; const DEFAULT_TYPE_SPEED = 30; const DEFAULT_BOX_ANIMATION_SPEED = 0; const DEFAULT_BOX_POSITION = 'bottom'; const TYPEWRITER_DELAY_MULTIPLIER = 1000; // Convert typeSpeed to milliseconds const CHOICE_BOX_OFFSET_X = 40; const CHOICE_BOX_OFFSET_Y = 12; export default class VisualNovelDialogue { constructor(scene, config = {}) { this.data = null; this.currentLabel = null; this.currentLineIndex = 0; this.isActive = false; this.isPaused = false; this.currentTypewriterText = ''; this.typewriterIndex = 0; this.isTypewriting = false; this.isShowingChoices = false; this.scene = scene; this.config = { fontFamily: DEFAULT_FONT_FAMILY, typeSpeed: DEFAULT_TYPE_SPEED, boxStyle: 'default', autoForward: false, boxAnimationSpeed: DEFAULT_BOX_ANIMATION_SPEED, boxPosition: DEFAULT_BOX_POSITION, styles: {}, audio: {}, debug: false, ...config }; // Create dialogue box this.dialogueBox = new DialogueBox(scene, this.config); if (this.config.debug) { console.log('VisualNovelDialogue initialized with config:', this.config); } } /** * Load dialogue data from JSON file or object * @param dataOrPath - Dialogue data object or file path (file loading not yet implemented) */ load(dataOrPath) { if (typeof dataOrPath === 'string') { // Load from file path - this will be implemented later console.warn('Loading from file path not yet implemented'); return; } this.data = dataOrPath; this.currentLabel = null; this.currentLineIndex = 0; this.isActive = false; this.isPaused = false; if (this.config.debug) { console.log('Dialogue data loaded:', this.data); } } /** * Start dialogue at the specified label * @param label - The label to start from (defaults to 'Start') */ start(label = 'Start') { if (!this.data) { console.error('No dialogue data loaded. Call load() first.'); return; } if (!this.data.script[label]) { console.error(`Label "${label}" not found in dialogue script.`); return; } this.currentLabel = label; this.currentLineIndex = 0; this.isActive = true; this.isPaused = false; if (this.config.debug) { console.log(`Starting dialogue at label: ${label}`); } this.processNextLine(); } /** * Jump to a specific label in the dialogue script * @param label - The label to jump to */ jumpTo(label) { if (!this.data || !this.data.script[label]) { console.error(`Label "${label}" not found in dialogue script.`); return; } this.currentLabel = label; this.currentLineIndex = 0; if (this.config.debug) { console.log(`Jumped to label: ${label}`); } this.processNextLine(); } /** * Pause the dialogue (prevents advancement) */ pause() { this.isPaused = true; if (this.config.debug) { console.log('Dialogue paused'); } } /** * Resume the dialogue (allows advancement to continue) */ resume() { this.isPaused = false; if (this.config.debug) { console.log('Dialogue resumed'); } this.processNextLine(); } /** * Process the next line in the current script */ processNextLine() { if (!this.data || !this.currentLabel || this.isPaused) { return; } const script = this.data.script[this.currentLabel]; if (!script || this.currentLineIndex >= script.length) { this.endDialogue(); return; } const line = script[this.currentLineIndex]; this.currentLineIndex++; if (this.config.debug) { console.log(`Processing line ${this.currentLineIndex}:`, line); } if (line !== undefined) { this.processLine(line); } else { this.endDialogue(); } } /** * Process a single line or command */ processLine(line) { if (typeof line === 'string') { this.processStringLine(line); } else if (typeof line === 'object' && 'Choice' in line) { this.processChoice(line.Choice); } else { // Unknown command - treat as dialogue this.processStringLine(String(line)); } } /** * Process a string line (dialogue or command) */ processStringLine(line) { // Check for commands if (line.startsWith('jump ')) { const targetLabel = line.substring(5).trim(); if (targetLabel) { this.jumpTo(targetLabel); } else { this.endDialogue(); } return; } if (line === 'end') { this.endDialogue(); return; } if (line.startsWith('show ')) { const parts = line.substring(5).trim().split(' '); const characterId = parts[0] || ''; const emotion = parts[1] || ''; if (characterId) { this.onShow?.(characterId, emotion); } this.processNextLine(); return; } if (line.startsWith('hide ')) { const characterId = line.substring(5).trim(); if (characterId) { this.onHide?.(characterId); } this.processNextLine(); return; } // Treat as dialogue line this.displayDialogue(line); } /** * Process a choice command */ processChoice(choices) { // Hide dialogue box while choices are visible this.dialogueBox?.setVisible(false); // Destroy any previous choice box this.choiceBox?.destroy(); this.isShowingChoices = true; // Show choice box this.choiceBox = new ChoiceBox(this.scene, choices, (choiceText) => { // On select, jump to the label and show dialogue box again const targetLabel = choices[choiceText]; this.choiceBox?.destroy(); this.dialogueBox?.setVisible(true); this.isShowingChoices = false; if (typeof targetLabel === 'string') { this.onChoice?.(targetLabel, choiceText); this.jumpTo(targetLabel); } else { this.endDialogue(); } }); // Position the choice box below the dialogue box if (this.dialogueBox) { this.choiceBox.setPosition(this.dialogueBox.x + CHOICE_BOX_OFFSET_X, this.dialogueBox.y + this.dialogueBox.height + CHOICE_BOX_OFFSET_Y); } } /** * Display dialogue text in the dialogue box with typewriter effect */ displayDialogue(text) { if (this.config.debug) { console.log('Displaying dialogue:', text); } // Reset text style at the beginning of each new line this.dialogueBox?.resetTextStyle(); // Parse character dialogue (format: "characterId dialogue text") const spaceIndex = text.indexOf(' '); if (spaceIndex > 0) { const characterId = text.substring(0, spaceIndex); const dialogueText = text.substring(spaceIndex + 1); // Get character info from settings if (this.data?.settings.characters[characterId]) { const character = this.data.settings.characters[characterId]; this.dialogueBox?.setNameplate(character.name, character.color); this.startTypewriter(dialogueText); } else { // No character found, treat as narrator/plain text this.dialogueBox?.hideNameplate(); this.startTypewriter(text); } } else { // No character ID, treat as plain text this.dialogueBox?.hideNameplate(); this.startTypewriter(text); } this.onLineEnd?.(text); // Auto-advance if configured if (this.config.autoForward) { this.processNextLine(); } } /** * Start typewriter effect for text */ startTypewriter(text) { // Clear any existing typewriter this.stopTypewriter(); // Parse inline formatting const parsedText = this.parseInlineFormatting(text); this.currentTypewriterText = parsedText.text; this.typewriterIndex = 0; this.isTypewriting = true; // Store the style for use during typewriter this.currentStyle = parsedText.styles.length > 0 ? parsedText.styles[0] : undefined; // Set initial empty text (style already reset in displayDialogue) this.dialogueBox?.setText(''); // Calculate delay between characters (in milliseconds) const delay = TYPEWRITER_DELAY_MULTIPLIER / (this.config.typeSpeed || DEFAULT_TYPE_SPEED); // Start typewriter timer this.typewriterTimer = this.scene.time.addEvent({ delay: delay, callback: this.onTypewriterTick, callbackScope: this, loop: true }); } /** * Parse inline formatting tags */ parseInlineFormatting(text) { const styles = []; const audio = []; let cleanText = text; // Parse style tags: {style=value}text{/style} const styleRegex = /\{style=([^}]+)\}(.*?)\{\/style\}/g; cleanText = cleanText.replace(styleRegex, (match, styleName, content) => { const style = this.config.styles?.[styleName]; if (style) { styles.push({ ...style, content }); } return content; }); // Parse audio tags: {audio=value}{/audio} const audioRegex = /\{audio=([^}]+)\}\{\/audio\}/g; cleanText = cleanText.replace(audioRegex, (match, audioName) => { const audioFile = this.config.audio?.[audioName]; if (audioFile) { audio.push(audioFile); // Play audio immediately this.playAudio(audioFile); } return ''; }); return { text: cleanText, styles, audio }; } /** * Play audio file */ playAudio(audioKey) { try { this.scene.sound.play(audioKey); if (this.config.debug) { console.log(`Playing audio: ${audioKey}`); } } catch (error) { if (this.config.debug) { console.log(`Audio not found: ${audioKey}`); } } } /** * Apply text styling to dialogue box */ applyTextStyle(style) { if (!this.dialogueBox) return; // This is a simplified implementation // In a full implementation, you'd need to handle rich text formatting // For now, we'll just log the style application if (this.config.debug) { console.log('Applying style:', style); } } /** * Handle typewriter tick */ onTypewriterTick() { if (!this.isTypewriting || this.typewriterIndex >= this.currentTypewriterText.length) { this.stopTypewriter(); return; } this.typewriterIndex++; const displayText = this.currentTypewriterText.substring(0, this.typewriterIndex); // Always use setTextWithStyle to preserve the current style during typewriter if (this.currentStyle) { this.dialogueBox?.setTextWithStyle(displayText, this.currentStyle); } else { // For lines without style, we need to reset to default first this.dialogueBox?.resetTextStyle(); this.dialogueBox?.setText(displayText); } if (this.typewriterIndex >= this.currentTypewriterText.length) { this.stopTypewriter(); } } /** * Stop typewriter effect */ stopTypewriter() { if (this.typewriterTimer) { this.typewriterTimer.destroy(); this.typewriterTimer = undefined; } this.isTypewriting = false; } /** * Skip the current typewriter effect (show full text immediately) */ skipTypewriter() { if (this.isTypewriting) { this.stopTypewriter(); // Preserve the current style when skipping if (this.currentStyle) { this.dialogueBox?.setTextWithStyle(this.currentTypewriterText, this.currentStyle); } else { this.dialogueBox?.setText(this.currentTypewriterText); } } } /** * Check if typewriter is currently active * @returns true if typewriter is running */ isTypewriterActive() { return this.isTypewriting; } /** * Check if choices are currently being displayed * @returns true if choice UI is active */ isChoicesActive() { return this.isShowingChoices; } /** * Advance to the next line in the current script (manual progression) */ nextLine() { if (!this.isTypewriting && !this.isShowingChoices) { this.processNextLine(); } } /** * End the dialogue sequence */ endDialogue() { this.isActive = false; this.currentLabel = null; this.currentLineIndex = 0; // Hide dialogue and choice boxes this.dialogueBox?.setVisible(false); this.choiceBox?.destroy(); this.choiceBox = undefined; if (this.config.debug) { console.log('Dialogue ended'); } this.onEnd?.(); } /** * Show the dialogue box */ show() { this.dialogueBox?.setVisible(true); } /** * Hide the dialogue box */ hide() { this.dialogueBox?.setVisible(false); } } //# sourceMappingURL=VisualNovelDialogue.js.map