367 lines
9.4 KiB
JavaScript
367 lines
9.4 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);
|
|
}
|
|
|
|
// 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(
|
|
`<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 = [];
|
|
}
|
|
}
|