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