ritual.sh/assets/js/games/engine/scene-manager.js
2026-01-19 18:48:07 +00:00

391 lines
10 KiB
JavaScript

// Scene Manager - Handles scene definitions, rendering, and transitions
class SceneManager {
constructor(adapter, stateManager, inputManager) {
this.adapter = adapter;
this.state = stateManager;
this.input = inputManager;
this.scenes = {};
this.currentScene = null;
this.sceneHistory = [];
}
// 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);
}
this.currentScene = scene;
// 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);
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 || "");
}
continue;
}
}
}
// Print text with variable interpolation
_printText(text, className = "") {
// Support ${path} interpolation
const interpolated = text.replace(/\$\{([^}]+)\}/g, (match, path) => {
const value = this.state.get(path);
return value !== undefined ? String(value) : match;
});
if (className) {
this.adapter.print(interpolated, className);
} 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) {
const interpolated = this._interpolateText(text);
let output = "";
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="typewriter-line">${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));
}
// Get current scene ID
getCurrentSceneId() {
return this.currentScene ? this.currentScene.id : null;
}
// Reset scene history
resetHistory() {
this.sceneHistory = [];
}
}