// 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( `${scene.title}`, ); this.adapter.print(""); } // Render content blocks if (scene.content) { await this._renderContent(scene.content); } // 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 if (block.text !== undefined) { 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); } } // 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( `${output}`, ); } 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 = []; } }