614 lines
17 KiB
JavaScript
614 lines
17 KiB
JavaScript
// Scene Manager - Handles scene definitions, rendering, and transitions
|
|
class SceneManager {
|
|
constructor(adapter, stateManager, inputManager, soundManager = null) {
|
|
this.adapter = adapter;
|
|
this.state = stateManager;
|
|
this.input = inputManager;
|
|
this.sound = soundManager;
|
|
this.scenes = {};
|
|
this.currentScene = null;
|
|
this.sceneHistory = [];
|
|
this.activeSounds = new Map(); // Track sounds started in current scene
|
|
}
|
|
|
|
// Register scenes from game definition
|
|
registerScenes(sceneDefinitions) {
|
|
for (const [id, scene] of Object.entries(sceneDefinitions)) {
|
|
this.scenes[id] = { id, ...scene };
|
|
}
|
|
}
|
|
|
|
// Get scene by ID
|
|
getScene(sceneId) {
|
|
return this.scenes[sceneId];
|
|
}
|
|
|
|
// Navigate to a scene
|
|
async goTo(sceneId, options = {}) {
|
|
const scene = this.getScene(sceneId);
|
|
if (!scene) {
|
|
this.adapter.printError(`Scene not found: ${sceneId}`);
|
|
return;
|
|
}
|
|
|
|
// Track history for back navigation
|
|
if (this.currentScene && !options.noHistory) {
|
|
this.sceneHistory.push(this.currentScene.id);
|
|
}
|
|
|
|
// Stop scene-specific sounds from previous scene
|
|
await this._cleanupSceneSounds();
|
|
|
|
this.currentScene = scene;
|
|
|
|
// Preload sounds for this scene
|
|
if (this.sound && scene.sounds) {
|
|
await this._preloadSceneSounds(scene.sounds);
|
|
}
|
|
|
|
// Execute onEnter actions
|
|
if (scene.onEnter) {
|
|
await this._executeActions(scene.onEnter);
|
|
}
|
|
|
|
// Render the scene
|
|
await this._renderScene(scene);
|
|
}
|
|
|
|
// Go back to previous scene
|
|
async goBack() {
|
|
if (this.sceneHistory.length === 0) {
|
|
this.adapter.printWarning("No previous scene");
|
|
return;
|
|
}
|
|
const previousId = this.sceneHistory.pop();
|
|
await this.goTo(previousId, { noHistory: true });
|
|
}
|
|
|
|
// Render a scene
|
|
async _renderScene(scene) {
|
|
// Clear screen unless disabled
|
|
if (scene.clear !== false) {
|
|
this.adapter.clear();
|
|
}
|
|
|
|
// Render title
|
|
if (scene.title) {
|
|
this.adapter.printHTML(
|
|
`<span class="game-scene-title">${scene.title}</span>`,
|
|
);
|
|
this.adapter.print("");
|
|
}
|
|
|
|
// Render content blocks
|
|
if (scene.content) {
|
|
await this._renderContent(scene.content);
|
|
}
|
|
|
|
// Execute onAfterRender actions (after content, before options)
|
|
if (scene.onAfterRender) {
|
|
await this._executeActions(scene.onAfterRender);
|
|
}
|
|
|
|
// Handle navigation
|
|
if (scene.options) {
|
|
await this._handleOptions(scene);
|
|
} else if (scene.input) {
|
|
await this._handleTextInput(scene);
|
|
} else if (scene.next) {
|
|
// Auto-advance with optional delay
|
|
const delay = scene.delay || 0;
|
|
if (delay > 0) {
|
|
await this._sleep(delay);
|
|
}
|
|
await this.goTo(scene.next);
|
|
}
|
|
}
|
|
|
|
// Render content blocks (supports conditional content)
|
|
async _renderContent(content) {
|
|
const blocks = Array.isArray(content) ? content : [content];
|
|
|
|
for (const block of blocks) {
|
|
// Simple string
|
|
if (typeof block === "string") {
|
|
this._printText(block);
|
|
continue;
|
|
}
|
|
|
|
// Conditional block
|
|
if (block.condition !== undefined) {
|
|
if (this.state.evaluate(block.condition)) {
|
|
if (block.content) {
|
|
await this._renderContent(block.content);
|
|
}
|
|
} else if (block.else) {
|
|
await this._renderContent(block.else);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Typed blocks
|
|
if (block.type === "ascii") {
|
|
this.adapter.print(block.art, block.className || "game-ascii");
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "ansi") {
|
|
// Convert ANSI escape codes to HTML
|
|
const html = AnsiConverter.convertToBlock(
|
|
block.art,
|
|
block.className || "game-ansi-art",
|
|
);
|
|
this.adapter.printHTML(html);
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "html") {
|
|
this.adapter.printHTML(block.html, block.className || "");
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "delay") {
|
|
await this._sleep(block.ms || 1000);
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "typewriter") {
|
|
await this._typewriter(block.text, block.speed || 50, {
|
|
bold: block.bold,
|
|
italic: block.italic,
|
|
className: block.className,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "table") {
|
|
await this._renderTable(block);
|
|
continue;
|
|
}
|
|
|
|
if (block.type === "sound") {
|
|
await this._handleSound(block);
|
|
continue;
|
|
}
|
|
|
|
// Text with optional className (supports html: true for HTML content)
|
|
if (block.text !== undefined) {
|
|
if (block.html) {
|
|
this._printHTML(block.text, block.className || "");
|
|
} else {
|
|
this._printText(block.text, block.className || "", {
|
|
bold: block.bold,
|
|
italic: block.italic,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print text with variable interpolation
|
|
_printText(text, className = "", options = {}) {
|
|
// Support ${path} interpolation
|
|
const interpolated = text.replace(/\$\{([^}]+)\}/g, (match, path) => {
|
|
const value = this.state.get(path);
|
|
return value !== undefined ? String(value) : match;
|
|
});
|
|
|
|
// Build style classes based on options
|
|
let styleClasses = className;
|
|
if (options.bold)
|
|
styleClasses += (styleClasses ? " " : "") + "typewriter-bold";
|
|
if (options.italic)
|
|
styleClasses += (styleClasses ? " " : "") + "typewriter-italic";
|
|
|
|
if (styleClasses) {
|
|
this.adapter.print(interpolated, styleClasses);
|
|
} else {
|
|
this.adapter.print(interpolated);
|
|
}
|
|
}
|
|
|
|
// Print HTML with variable interpolation
|
|
_printHTML(html, className = "") {
|
|
// Support ${path} interpolation
|
|
const interpolated = html.replace(/\$\{([^}]+)\}/g, (match, path) => {
|
|
const value = this.state.get(path);
|
|
return value !== undefined ? String(value) : match;
|
|
});
|
|
|
|
if (className) {
|
|
this.adapter.printHTML(
|
|
`<span class="${className}">${interpolated}</span>`,
|
|
);
|
|
} else {
|
|
this.adapter.printHTML(interpolated);
|
|
}
|
|
}
|
|
|
|
// Handle options/choices
|
|
async _handleOptions(scene) {
|
|
// Filter options based on conditions
|
|
const availableOptions = scene.options.filter((opt) => {
|
|
if (opt.condition !== undefined) {
|
|
return this.state.evaluate(opt.condition);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (availableOptions.length === 0) {
|
|
if (scene.fallback) {
|
|
await this.goTo(scene.fallback);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const result = await this.input.awaitOption(
|
|
availableOptions.map((o) => ({
|
|
text: this._interpolateText(o.text),
|
|
value: o,
|
|
})),
|
|
scene.prompt || "What do you do?",
|
|
);
|
|
|
|
const selected = result.option.value;
|
|
|
|
// Execute option actions
|
|
if (selected.actions) {
|
|
await this._executeActions(selected.actions);
|
|
}
|
|
|
|
// Navigate to next scene
|
|
if (selected.next) {
|
|
await this.goTo(selected.next);
|
|
}
|
|
}
|
|
|
|
// Handle text input
|
|
async _handleTextInput(scene) {
|
|
const inputDef = scene.input;
|
|
const value = await this.input.awaitText(inputDef.prompt);
|
|
|
|
// Store value if specified
|
|
if (inputDef.store) {
|
|
this.state.set(inputDef.store, value);
|
|
}
|
|
|
|
// Check for pattern matches
|
|
if (inputDef.matches) {
|
|
for (const match of inputDef.matches) {
|
|
const pattern =
|
|
match.pattern instanceof RegExp
|
|
? match.pattern
|
|
: new RegExp(match.pattern, "i");
|
|
|
|
if (pattern.test(value)) {
|
|
if (match.actions) {
|
|
await this._executeActions(match.actions);
|
|
}
|
|
if (match.next) {
|
|
await this.goTo(match.next);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No match - use default
|
|
if (inputDef.default) {
|
|
if (inputDef.default.actions) {
|
|
await this._executeActions(inputDef.default.actions);
|
|
}
|
|
if (inputDef.default.next) {
|
|
await this.goTo(inputDef.default.next);
|
|
}
|
|
} else if (inputDef.next) {
|
|
await this.goTo(inputDef.next);
|
|
}
|
|
}
|
|
|
|
// Execute action commands
|
|
async _executeActions(actions) {
|
|
const actionList = Array.isArray(actions) ? actions : [actions];
|
|
|
|
for (const action of actionList) {
|
|
if (action.set !== undefined) {
|
|
this.state.set(action.set, action.value);
|
|
}
|
|
|
|
if (action.increment !== undefined) {
|
|
this.state.increment(action.increment, action.amount || 1);
|
|
}
|
|
|
|
if (action.decrement !== undefined) {
|
|
this.state.decrement(action.decrement, action.amount || 1);
|
|
}
|
|
|
|
if (action.addItem !== undefined) {
|
|
this.state.addToArray(action.to || "inventory", action.addItem);
|
|
}
|
|
|
|
if (action.removeItem !== undefined) {
|
|
this.state.removeFromArray(
|
|
action.from || "inventory",
|
|
action.removeItem,
|
|
);
|
|
}
|
|
|
|
if (action.print !== undefined) {
|
|
this._printText(action.print, action.className || "");
|
|
}
|
|
|
|
if (action.printSuccess !== undefined) {
|
|
this.adapter.printSuccess(this._interpolateText(action.printSuccess));
|
|
}
|
|
|
|
if (action.printError !== undefined) {
|
|
this.adapter.printError(this._interpolateText(action.printError));
|
|
}
|
|
|
|
if (action.printWarning !== undefined) {
|
|
this.adapter.printWarning(this._interpolateText(action.printWarning));
|
|
}
|
|
|
|
if (action.printInfo !== undefined) {
|
|
this.adapter.printInfo(this._interpolateText(action.printInfo));
|
|
}
|
|
|
|
if (action.delay !== undefined) {
|
|
await this._sleep(action.delay);
|
|
}
|
|
|
|
if (action.goTo !== undefined) {
|
|
await this.goTo(action.goTo);
|
|
return; // Stop processing further actions after navigation
|
|
}
|
|
|
|
if (action.callback && typeof action.callback === "function") {
|
|
await action.callback(this.state, this.adapter, this);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Interpolate variables in text
|
|
_interpolateText(text) {
|
|
if (typeof text !== "string") return text;
|
|
return text.replace(/\$\{([^}]+)\}/g, (match, path) => {
|
|
const value = this.state.get(path);
|
|
return value !== undefined ? String(value) : match;
|
|
});
|
|
}
|
|
|
|
// Typewriter effect
|
|
async _typewriter(text, speed, options = {}) {
|
|
const interpolated = this._interpolateText(text);
|
|
let output = "";
|
|
|
|
// Build style classes based on options
|
|
let styleClasses = "typewriter-line";
|
|
if (options.bold) styleClasses += " typewriter-bold";
|
|
if (options.italic) styleClasses += " typewriter-italic";
|
|
if (options.className) styleClasses += " " + options.className;
|
|
|
|
for (const char of interpolated) {
|
|
output += char;
|
|
// Create a single updating line for typewriter
|
|
const typewriterSpan =
|
|
this.adapter.terminal.output.querySelector(".typewriter-line");
|
|
|
|
if (typewriterSpan) {
|
|
typewriterSpan.textContent = output;
|
|
} else {
|
|
this.adapter.printHTML(
|
|
`<span class="${styleClasses}">${output}</span>`,
|
|
);
|
|
}
|
|
|
|
this.adapter.scrollToBottom();
|
|
await this._sleep(speed);
|
|
}
|
|
|
|
// Finalize the line - remove the typewriter-line class so future typewriters create new lines
|
|
const typewriterSpan =
|
|
this.adapter.terminal.output.querySelector(".typewriter-line");
|
|
if (typewriterSpan) {
|
|
typewriterSpan.classList.remove("typewriter-line");
|
|
}
|
|
}
|
|
|
|
_sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Render a dynamic table with conditional row support
|
|
async _renderTable(block) {
|
|
// Filter rows based on conditions
|
|
const filteredRows = [];
|
|
for (const row of block.rows || []) {
|
|
// Row can be: array of cells, or object with { cells, condition, className }
|
|
if (Array.isArray(row)) {
|
|
// Simple row - array of cells
|
|
filteredRows.push(row);
|
|
} else if (row.condition !== undefined) {
|
|
// Conditional row
|
|
if (this.state.evaluate(row.condition)) {
|
|
filteredRows.push(this._processTableRow(row));
|
|
}
|
|
} else if (row.cells) {
|
|
// Object row without condition
|
|
filteredRows.push(this._processTableRow(row));
|
|
}
|
|
}
|
|
|
|
// Build table using TableHelper
|
|
const tableOutput = TableHelper.table({
|
|
title: block.title ? this._interpolateText(block.title) : undefined,
|
|
headers: block.headers,
|
|
rows: filteredRows,
|
|
widths: block.widths,
|
|
align: block.align,
|
|
style: block.style || "single",
|
|
padding: block.padding,
|
|
});
|
|
|
|
// Render each line of the table
|
|
for (const line of tableOutput) {
|
|
if (typeof line === "string") {
|
|
this._printText(line);
|
|
} else if (line.text) {
|
|
this._printText(line.text, line.className || "");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process a table row object into cell array format for TableHelper
|
|
_processTableRow(row) {
|
|
// If row has className, apply it to cells that don't have their own
|
|
const cells = row.cells.map((cell) => {
|
|
if (typeof cell === "string" && row.className) {
|
|
return { text: this._interpolateText(cell), className: row.className };
|
|
} else if (typeof cell === "object" && cell.text) {
|
|
return {
|
|
text: this._interpolateText(cell.text),
|
|
className: cell.className || row.className,
|
|
};
|
|
}
|
|
return typeof cell === "string" ? this._interpolateText(cell) : cell;
|
|
});
|
|
return cells;
|
|
}
|
|
|
|
// Get current scene ID
|
|
getCurrentSceneId() {
|
|
return this.currentScene ? this.currentScene.id : null;
|
|
}
|
|
|
|
// Reset scene history
|
|
resetHistory() {
|
|
this.sceneHistory = [];
|
|
}
|
|
|
|
// Preload sounds for a scene
|
|
async _preloadSceneSounds(sounds) {
|
|
if (!this.sound) return;
|
|
|
|
const soundList = Array.isArray(sounds) ? sounds : [sounds];
|
|
let hasShownLoading = false;
|
|
|
|
for (const soundDef of soundList) {
|
|
const soundId = soundDef.id;
|
|
const url = soundDef.url || soundDef.src;
|
|
|
|
if (!soundId || !url) {
|
|
console.warn("Invalid sound definition:", soundDef);
|
|
continue;
|
|
}
|
|
|
|
// Skip if already loaded
|
|
if (this.sound.isLoaded(soundId)) {
|
|
continue;
|
|
}
|
|
|
|
// Show loading indicator if not shown yet
|
|
if (!hasShownLoading) {
|
|
this.adapter.printHTML(
|
|
'<span class="sound-loading info">Loading audio...</span>',
|
|
);
|
|
hasShownLoading = true;
|
|
}
|
|
|
|
try {
|
|
await this.sound.preload(soundId, url);
|
|
} catch (error) {
|
|
console.error(`Failed to preload sound ${soundId}:`, error);
|
|
// Continue loading other sounds even if one fails
|
|
}
|
|
}
|
|
|
|
// Remove loading indicator
|
|
if (hasShownLoading) {
|
|
const indicator =
|
|
this.adapter.terminal.output.querySelector(".sound-loading");
|
|
if (indicator) {
|
|
indicator.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle sound playback in content blocks
|
|
async _handleSound(block) {
|
|
if (!this.sound) {
|
|
console.warn("Sound manager not available");
|
|
return;
|
|
}
|
|
|
|
const action = block.action || "play"; // play, stop, stopAll
|
|
const soundId = block.id || block.sound;
|
|
|
|
try {
|
|
if (action === "play") {
|
|
const options = {
|
|
loop: block.loop || false,
|
|
volume: block.volume !== undefined ? block.volume : 1.0,
|
|
fade: block.fade || false,
|
|
fadeDuration: block.fadeDuration || 1000,
|
|
};
|
|
|
|
const controller = await this.sound.play(soundId, options);
|
|
|
|
// Store reference for cleanup unless it's a one-shot sound
|
|
if (block.loop || block.persist) {
|
|
this.activeSounds.set(soundId, controller);
|
|
}
|
|
|
|
// Auto-stop after duration if specified
|
|
if (block.duration) {
|
|
setTimeout(() => {
|
|
if (block.fadeOut !== false) {
|
|
controller.fadeOut(block.fadeDuration || 1000);
|
|
} else {
|
|
controller.stop();
|
|
}
|
|
}, block.duration);
|
|
}
|
|
} else if (action === "stop") {
|
|
const controller = this.activeSounds.get(soundId);
|
|
if (controller) {
|
|
if (block.fadeOut !== false) {
|
|
await controller.fadeOut(block.fadeDuration || 1000);
|
|
} else {
|
|
controller.stop();
|
|
}
|
|
this.activeSounds.delete(soundId);
|
|
}
|
|
} else if (action === "stopAll") {
|
|
await this._cleanupSceneSounds();
|
|
}
|
|
} catch (error) {
|
|
console.error(`Sound error (${action} ${soundId}):`, error);
|
|
// Don't show error to user, just log it
|
|
}
|
|
}
|
|
|
|
// Clean up sounds when leaving a scene
|
|
async _cleanupSceneSounds() {
|
|
if (!this.sound) return;
|
|
|
|
const fadePromises = [];
|
|
|
|
for (const [, controller] of this.activeSounds) {
|
|
if (controller.fadeOut) {
|
|
fadePromises.push(
|
|
controller.fadeOut(500).catch((e) => console.error("Fade error:", e)),
|
|
);
|
|
} else {
|
|
controller.stop();
|
|
}
|
|
}
|
|
|
|
// Wait for all fades to complete
|
|
await Promise.all(fadePromises);
|
|
this.activeSounds.clear();
|
|
}
|
|
}
|