game engine start

This commit is contained in:
Dan 2026-01-18 16:44:16 +00:00
parent d0c65a71ad
commit d5510eb989
10 changed files with 3074 additions and 1 deletions

View file

@ -0,0 +1,212 @@
// ANSI to HTML Converter
// Converts ANSI escape sequences (256-color) to HTML spans with inline styles
// Supports: 38;5;N (foreground), 48;5;N (background), and standard reset codes
const AnsiConverter = {
// 256-color palette - standard xterm colors
palette: [
// Standard colors (0-15)
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
"#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
// 216 color cube (16-231)
...(() => {
const colors = [];
const levels = [0, 95, 135, 175, 215, 255];
for (let r = 0; r < 6; r++) {
for (let g = 0; g < 6; g++) {
for (let b = 0; b < 6; b++) {
colors.push(`#${levels[r].toString(16).padStart(2, "0")}${levels[g].toString(16).padStart(2, "0")}${levels[b].toString(16).padStart(2, "0")}`);
}
}
}
return colors;
})(),
// Grayscale (232-255)
...(() => {
const grays = [];
for (let i = 0; i < 24; i++) {
const level = 8 + i * 10;
const hex = level.toString(16).padStart(2, "0");
grays.push(`#${hex}${hex}${hex}`);
}
return grays;
})(),
],
// Get color from palette by index
getColor(index) {
if (index >= 0 && index < this.palette.length) {
return this.palette[index];
}
return null;
},
// Parse ANSI escape sequence and return style object
parseEscapeCode(code) {
const style = {};
const parts = code.split(";");
let i = 0;
while (i < parts.length) {
const num = parseInt(parts[i], 10);
// Reset
if (num === 0 || num === "m" || isNaN(num)) {
style.reset = true;
i++;
continue;
}
// 256-color foreground: 38;5;N
if (num === 38 && parts[i + 1] === "5") {
const colorIndex = parseInt(parts[i + 2], 10);
const color = this.getColor(colorIndex);
if (color) {
style.fg = color;
}
i += 3;
continue;
}
// 256-color background: 48;5;N
if (num === 48 && parts[i + 1] === "5") {
const colorIndex = parseInt(parts[i + 2], 10);
const color = this.getColor(colorIndex);
if (color) {
style.bg = color;
}
i += 3;
continue;
}
// Standard foreground colors (30-37)
if (num >= 30 && num <= 37) {
style.fg = this.palette[num - 30];
i++;
continue;
}
// Bright foreground colors (90-97)
if (num >= 90 && num <= 97) {
style.fg = this.palette[num - 90 + 8];
i++;
continue;
}
// Standard background colors (40-47)
if (num >= 40 && num <= 47) {
style.bg = this.palette[num - 40];
i++;
continue;
}
// Bright background colors (100-107)
if (num >= 100 && num <= 107) {
style.bg = this.palette[num - 100 + 8];
i++;
continue;
}
// Bold (1) - we'll treat as bright
if (num === 1) {
style.bold = true;
i++;
continue;
}
i++;
}
return style;
},
// Convert ANSI string to HTML
convert(ansiString) {
// Regex to match ANSI escape sequences
// Matches: \e[...m or \x1b[...m or actual escape character
const ansiRegex = /(?:\x1b|\u001b|\\e|\\x1b)\[([0-9;]*)m/g;
let html = "";
let currentFg = null;
let currentBg = null;
let lastIndex = 0;
let spanOpen = false;
// Replace escaped representations with actual escape character for easier processing
let processed = ansiString
.replace(/\\e/g, "\x1b")
.replace(/\\x1b/g, "\x1b");
let match;
while ((match = ansiRegex.exec(processed)) !== null) {
// Add text before this escape sequence
const textBefore = processed.slice(lastIndex, match.index);
if (textBefore) {
html += this.escapeHtml(textBefore);
}
// Parse the escape code
const style = this.parseEscapeCode(match[1]);
// Close previous span if needed
if (spanOpen) {
html += "</span>";
spanOpen = false;
}
// Handle reset
if (style.reset) {
currentFg = null;
currentBg = null;
}
// Update current colors
if (style.fg) currentFg = style.fg;
if (style.bg) currentBg = style.bg;
// Open new span if we have colors
if (currentFg || currentBg) {
const styles = [];
if (currentFg) styles.push(`color:${currentFg}`);
if (currentBg) styles.push(`background-color:${currentBg}`);
html += `<span style="${styles.join(";")}">`;
spanOpen = true;
}
lastIndex = match.index + match[0].length;
}
// Add remaining text
const remaining = processed.slice(lastIndex);
if (remaining) {
html += this.escapeHtml(remaining);
}
// Close any open span
if (spanOpen) {
html += "</span>";
}
return html;
},
// Escape HTML special characters (but preserve our spans)
escapeHtml(text) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
},
// Convert and wrap in a pre-formatted container for proper display
convertToBlock(ansiString, className = "ansi-art") {
const html = this.convert(ansiString);
return `<pre class="${className}">${html}</pre>`;
},
};
// Export for use in other modules
if (typeof window !== "undefined") {
window.AnsiConverter = AnsiConverter;
}

View file

@ -0,0 +1,210 @@
// Game Engine - Main orchestrator for terminal games
class GameEngine {
constructor(gameDefinition) {
this.definition = gameDefinition;
this.terminal = null;
this.adapter = null;
this.state = null;
this.input = null;
this.scenes = null;
this.isRunning = false;
this.originalExecuteCommand = null;
}
// Register this game as a terminal command
register() {
if (!window.terminal) {
console.warn("Terminal not available, cannot register game:", this.definition.id);
return;
}
this.terminal = window.terminal;
const self = this;
const def = this.definition;
this.terminal.registerCommand(
def.command || def.id,
def.description || `Play ${def.name}`,
async (args) => {
if (args[0] === "reset") {
self._reset();
return;
}
if (args[0] === "continue" || args[0] === "resume") {
await self.start(true);
return;
}
await self.start();
},
);
}
// Start the game
async start(continueGame = false) {
if (this.isRunning) {
this.terminal.printWarning("Game is already running!");
return;
}
this.isRunning = true;
// Initialize components
this.adapter = new TerminalAdapter(this.terminal);
this.state = new StateManager(this.definition.id);
this.input = new InputManager(this.adapter);
this.scenes = new SceneManager(this.adapter, this.state, this.input);
// Initialize state
this.state.init(this.definition.initialState || {});
// Register scenes
this.scenes.registerScenes(this.definition.scenes);
// Hook into terminal input
this._hookInput();
// Check for existing save
const hasSave = this.state.hasSavedState();
// Show intro unless continuing
if (!continueGame && !hasSave) {
if (this.definition.intro) {
await this._playIntro();
}
} else if (hasSave && !continueGame) {
// Ask if player wants to continue
this.adapter.clear();
this.adapter.printInfo("A saved game was found.");
const shouldContinue = await this.input.awaitConfirm("Continue from where you left off?");
if (!shouldContinue) {
this.state.reset();
this.state.init(this.definition.initialState || {});
if (this.definition.intro) {
await this._playIntro();
}
}
}
// Go to start scene (or last scene if continuing)
const startScene = this.state.get("_currentScene") || this.definition.startScene || "start";
await this.scenes.goTo(startScene);
}
async _playIntro() {
this.adapter.clear();
// Show title
if (this.definition.name) {
this.adapter.printHTML(
`<div class="game-title">${this.definition.name}</div>`,
);
this.adapter.print("");
}
// Show intro content
if (typeof this.definition.intro === "string") {
this.adapter.print(this.definition.intro);
} else if (Array.isArray(this.definition.intro)) {
await this.scenes._renderContent(this.definition.intro);
}
this.adapter.print("");
// Wait for input to continue
await this.input.awaitOption(
[{ text: "Begin", value: true }],
"Press Enter or 1 to start...",
);
}
_hookInput() {
// Store original executeCommand
this.originalExecuteCommand = this.terminal.executeCommand.bind(this.terminal);
// Override to intercept input
const self = this;
this.terminal.executeCommand = function (commandString) {
if (!self.isRunning) {
return self.originalExecuteCommand(commandString);
}
// Check for exit commands
const cmd = commandString.toLowerCase().trim();
if (cmd === "quit" || cmd === "exit" || cmd === "q") {
self.stop();
return;
}
// Check for save command
if (cmd === "save") {
self._saveProgress();
return;
}
// Pass to input manager if in text mode
if (self.input.getMode() === "text") {
self.input.handleTextInput(commandString);
}
};
}
_saveProgress() {
// Save current scene for resuming
const currentSceneId = this.scenes.getCurrentSceneId();
if (currentSceneId) {
this.state.set("_currentScene", currentSceneId);
}
this.adapter.printSuccess("Game progress saved.");
}
// Stop the game and restore terminal
stop() {
// Save progress before stopping
this._saveProgress();
this.isRunning = false;
// Cleanup input manager
if (this.input) {
this.input.destroy();
}
// Restore original command handler
if (this.originalExecuteCommand) {
this.terminal.executeCommand = this.originalExecuteCommand;
}
this.adapter.print("");
this.adapter.printInfo(`Exited ${this.definition.name}. Progress saved.`);
this.adapter.print('Type "help" for available commands.');
this.adapter.print(
`Type "${this.definition.command || this.definition.id}" to continue playing.`,
);
}
_reset() {
if (this.state) {
this.state.reset();
} else {
// Create temporary state manager just to reset
const tempState = new StateManager(this.definition.id);
tempState.reset();
}
this.terminal.printSuccess(`${this.definition.name} progress has been reset.`);
}
// Check if game is currently running
isActive() {
return this.isRunning;
}
// Get game definition
getDefinition() {
return this.definition;
}
}
// Make available globally
window.GameEngine = GameEngine;

View file

@ -0,0 +1,205 @@
// Input Manager - Handles text input and option selection modes
class InputManager {
constructor(adapter) {
this.adapter = adapter;
this.mode = "idle"; // "idle" | "text" | "options"
this.options = [];
this.selectedIndex = 0;
this.inputResolve = null;
this.optionsContainerId = "game-options-" + Date.now();
this.keydownHandler = null;
this._setupKeyboardListener();
}
_setupKeyboardListener() {
this.keydownHandler = (e) => {
if (this.mode !== "options") return;
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
this._selectPrevious();
} else if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
this._selectNext();
} else if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
this._confirmSelection();
} else if (/^[1-9]$/.test(e.key)) {
const index = parseInt(e.key) - 1;
if (index < this.options.length) {
e.preventDefault();
e.stopPropagation();
this.selectedIndex = index;
this._confirmSelection();
}
}
};
document.addEventListener("keydown", this.keydownHandler, true);
}
// Text input mode - wait for user to type something
awaitText(prompt = "") {
return new Promise((resolve) => {
if (prompt) {
this.adapter.printInfo(prompt);
}
this.mode = "text";
this.inputResolve = resolve;
this.adapter.captureInput((value) => {
this.adapter.print(`> ${value}`);
this.mode = "idle";
this.adapter.releaseInput();
if (this.inputResolve) {
const res = this.inputResolve;
this.inputResolve = null;
res(value);
}
});
this.adapter.focusInput();
});
}
// Options selection mode - display choices and wait for selection
awaitOption(options, prompt = "") {
return new Promise((resolve) => {
this.mode = "options";
this.options = options;
this.selectedIndex = 0;
this.optionsContainerId = "game-options-" + Date.now();
if (prompt) {
this.adapter.print("");
this.adapter.printInfo(prompt);
}
this.adapter.print("");
this._renderOptions();
this.inputResolve = (index) => {
this.mode = "idle";
resolve({
index,
option: this.options[index],
});
};
});
}
// Yes/No confirmation
awaitConfirm(prompt = "Continue?") {
return this.awaitOption(
[
{ text: "Yes", value: true },
{ text: "No", value: false },
],
prompt,
).then((result) => result.option.value);
}
// Handle text input from game engine
handleTextInput(value) {
if (this.mode === "text" && this.inputResolve) {
this.adapter.print(`> ${value}`);
this.mode = "idle";
const res = this.inputResolve;
this.inputResolve = null;
res(value);
}
}
_renderOptions() {
const optionsHTML = this.options
.map((opt, idx) => {
const selected = idx === this.selectedIndex;
const prefix = selected ? ">" : " ";
const className = selected ? "game-option-selected" : "game-option";
const text = typeof opt === "string" ? opt : opt.text;
return `<div class="${className}" data-index="${idx}">${prefix} ${idx + 1}. ${text}</div>`;
})
.join("");
this.adapter.printHTML(
`<div id="${this.optionsContainerId}" class="game-options">${optionsHTML}</div>`,
);
this.adapter.scrollToBottom();
}
_updateOptionsDisplay() {
const container = document.getElementById(this.optionsContainerId);
if (!container) return;
const optionDivs = container.querySelectorAll("div");
optionDivs.forEach((div, idx) => {
const selected = idx === this.selectedIndex;
div.className = selected ? "game-option-selected" : "game-option";
const text =
typeof this.options[idx] === "string"
? this.options[idx]
: this.options[idx].text;
div.textContent = `${selected ? ">" : " "} ${idx + 1}. ${text}`;
});
}
_selectPrevious() {
if (this.selectedIndex > 0) {
this.selectedIndex--;
this._updateOptionsDisplay();
}
}
_selectNext() {
if (this.selectedIndex < this.options.length - 1) {
this.selectedIndex++;
this._updateOptionsDisplay();
}
}
_confirmSelection() {
if (this.inputResolve) {
const res = this.inputResolve;
this.inputResolve = null;
// Show what was selected
const selectedOpt = this.options[this.selectedIndex];
const text =
typeof selectedOpt === "string" ? selectedOpt : selectedOpt.text;
this.adapter.print("");
this.adapter.printSuccess(`> ${text}`);
res(this.selectedIndex);
}
}
// Check if currently waiting for input
isWaiting() {
return this.mode !== "idle";
}
// Get current mode
getMode() {
return this.mode;
}
// Cancel current input (for cleanup)
cancel() {
this.mode = "idle";
this.inputResolve = null;
this.adapter.releaseInput();
}
// Cleanup when game ends
destroy() {
if (this.keydownHandler) {
document.removeEventListener("keydown", this.keydownHandler, true);
}
this.cancel();
}
}

View file

@ -0,0 +1,367 @@
// 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 = [];
}
}

View file

@ -0,0 +1,208 @@
// State Manager - Manages game state with persistence and conditions
class StateManager {
constructor(gameId) {
this.gameId = gameId;
this.state = {};
this.storageKey = `game_${gameId}_state`;
}
// Initialize with default state
init(defaultState = {}) {
this.state = this._deepClone(defaultState);
this._loadFromStorage();
}
// Get value using dot notation path (e.g., "inventory.sword")
get(path, defaultValue = undefined) {
const parts = path.split(".");
let current = this.state;
for (const part of parts) {
if (current === undefined || current === null) {
return defaultValue;
}
current = current[part];
}
return current !== undefined ? current : defaultValue;
}
// Set value using dot notation path
set(path, value) {
const parts = path.split(".");
let current = this.state;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current[part] === undefined) {
current[part] = {};
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
this._saveToStorage();
}
// Increment a numeric value
increment(path, amount = 1) {
const current = this.get(path, 0);
this.set(path, current + amount);
}
// Decrement a numeric value
decrement(path, amount = 1) {
this.increment(path, -amount);
}
// Add item to an array (if not already present)
addToArray(path, item) {
const arr = this.get(path, []);
if (!arr.includes(item)) {
arr.push(item);
this.set(path, arr);
}
}
// Remove item from an array
removeFromArray(path, item) {
const arr = this.get(path, []);
const index = arr.indexOf(item);
if (index > -1) {
arr.splice(index, 1);
this.set(path, arr);
}
}
// Check if array contains item
hasItem(path, item) {
const arr = this.get(path, []);
return arr.includes(item);
}
// Evaluate a condition against current state
evaluate(condition) {
if (typeof condition === "boolean") {
return condition;
}
if (typeof condition === "string") {
// Simple path check - truthy value
return !!this.get(condition);
}
if (typeof condition === "object" && condition !== null) {
return this._evaluateConditionObject(condition);
}
return true;
}
_evaluateConditionObject(cond) {
// Logical operators
if (cond.and) {
return cond.and.every((c) => this.evaluate(c));
}
if (cond.or) {
return cond.or.some((c) => this.evaluate(c));
}
if (cond.not) {
return !this.evaluate(cond.not);
}
// Value comparisons
const value = this.get(cond.path);
if ("equals" in cond) {
return value === cond.equals;
}
if ("notEquals" in cond) {
return value !== cond.notEquals;
}
if ("greaterThan" in cond) {
return value > cond.greaterThan;
}
if ("greaterThanOrEqual" in cond) {
return value >= cond.greaterThanOrEqual;
}
if ("lessThan" in cond) {
return value < cond.lessThan;
}
if ("lessThanOrEqual" in cond) {
return value <= cond.lessThanOrEqual;
}
if ("contains" in cond) {
return Array.isArray(value) && value.includes(cond.contains);
}
if ("notContains" in cond) {
return !Array.isArray(value) || !value.includes(cond.notContains);
}
// Default: check truthiness of path
return !!value;
}
// Get entire state (for debugging)
getAll() {
return this._deepClone(this.state);
}
// Reset state and clear storage
reset() {
this.state = {};
try {
localStorage.removeItem(this.storageKey);
} catch (e) {
console.warn("Failed to clear game state from storage:", e);
}
}
// Check if there is saved state
hasSavedState() {
try {
return localStorage.getItem(this.storageKey) !== null;
} catch (e) {
return false;
}
}
_saveToStorage() {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.state));
} catch (e) {
console.warn("Failed to save game state:", e);
}
}
_loadFromStorage() {
try {
const saved = localStorage.getItem(this.storageKey);
if (saved) {
const parsed = JSON.parse(saved);
this.state = this._mergeDeep(this.state, parsed);
}
} catch (e) {
console.warn("Failed to load game state:", e);
}
}
_deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
_mergeDeep(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === "object" &&
!Array.isArray(source[key])
) {
result[key] = this._mergeDeep(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
}

View file

@ -0,0 +1,70 @@
// Terminal Adapter - Bridges game engine to existing TerminalShell
class TerminalAdapter {
constructor(terminal) {
this.terminal = terminal;
this.inputCallback = null;
}
// Output methods - delegate to terminal
print(text, className = "") {
this.terminal.print(text, className);
}
printHTML(html, className = "") {
this.terminal.printHTML(html, className);
}
printError(text) {
this.terminal.printError(text);
}
printSuccess(text) {
this.terminal.printSuccess(text);
}
printInfo(text) {
this.terminal.printInfo(text);
}
printWarning(text) {
this.terminal.printWarning(text);
}
clear() {
this.terminal.clear();
}
scrollToBottom() {
this.terminal.scrollToBottom();
}
// Input capture - allows game to intercept terminal input
captureInput(callback) {
this.inputCallback = callback;
}
releaseInput() {
this.inputCallback = null;
}
// Called by game engine when input is received
handleInput(value) {
if (this.inputCallback) {
this.inputCallback(value);
return true;
}
return false;
}
// Get the input element for focus management
getInputElement() {
return this.terminal.input;
}
// Focus the input
focusInput() {
if (this.terminal.input) {
this.terminal.input.focus();
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,281 @@
// Test Adventure - A simple game to test the game engine
const testAdventureGame = {
id: "test-adventure",
name: "Terminal Quest",
command: "quest",
description: "A test adventure game",
initialState: {
gold: 0,
inventory: [],
visited: {},
},
intro: [
{
type: "ascii",
art: `
TERMINAL QUEST
A Test Adventure
`,
className: "game-ascii",
},
"",
"You wake up in a strange digital world...",
{ type: "delay", ms: 800 },
"The cursor blinks patiently before you.",
"",
{ text: 'Type "quit" at any time to exit.', className: "info" },
],
startScene: "start",
scenes: {
start: {
title: "The Terminal",
content: [
"You find yourself at a command prompt.",
"A green cursor blinks steadily in the darkness.",
"",
{
condition: { path: "visited.start" },
content: { text: "You've been here before...", className: "info" },
},
{
condition: { path: "gold", greaterThan: 0 },
content: "Your gold pouch jingles with ${gold} coins.",
},
],
onEnter: [{ set: "visited.start", value: true }],
options: [
{ text: "Look around", next: "look-around" },
{ text: "Check inventory", next: "inventory" },
{
text: "Enter the code cave",
condition: { path: "inventory", contains: "flashlight" },
next: "code-cave",
},
{
text: "Talk to the cursor",
condition: { not: { path: "visited.cursor-talk" } },
next: "cursor-talk",
},
],
},
"look-around": {
title: "Searching...",
content: ["You search the area carefully...", { type: "delay", ms: 500 }],
onEnter: [
{
callback: (state, adapter) => {
// Random chance to find gold
if (Math.random() > 0.5) {
const found = Math.floor(Math.random() * 10) + 1;
state.increment("gold", found);
adapter.printSuccess(`You found ${found} gold coins!`);
} else {
adapter.print("You don't find anything this time.");
}
},
},
],
next: "start",
delay: 1000,
},
inventory: {
title: "Inventory",
clear: false,
content: [
"",
"=== Your Inventory ===",
{ text: "Gold: ${gold}", className: "warning" },
"",
{
condition: { path: "inventory", contains: "flashlight" },
content: "- Flashlight (lights up dark places)",
},
{
condition: { path: "inventory", contains: "key" },
content: "- Mysterious Key",
},
{
condition: {
and: [
{ not: { path: "inventory", contains: "flashlight" } },
{ not: { path: "inventory", contains: "key" } },
],
},
content: { text: "Your inventory is empty.", className: "info" },
},
"",
],
options: [{ text: "Back", next: "start" }],
},
"cursor-talk": {
title: "The Blinking Cursor",
content: [
"The cursor seems to acknowledge you.",
"",
{ type: "typewriter", text: "HELLO, USER.", speed: 80 },
{ type: "delay", ms: 500 },
{ type: "typewriter", text: "I HAVE BEEN WAITING FOR YOU.", speed: 60 },
{ type: "delay", ms: 300 },
"",
"The cursor offers you something...",
],
onEnter: [
{ set: "visited.cursor-talk", value: true },
{ addItem: "flashlight" },
{ printSuccess: "You received a flashlight!" },
],
options: [
{ text: "Thank the cursor", next: "cursor-thanks" },
{ text: "Ask about the code cave", next: "cursor-cave-info" },
],
},
"cursor-thanks": {
content: [
{ type: "typewriter", text: "YOU ARE WELCOME, USER.", speed: 60 },
"",
"The cursor resumes its patient blinking.",
],
next: "start",
delay: 1500,
},
"cursor-cave-info": {
content: [
{ type: "typewriter", text: "THE CAVE HOLDS SECRETS...", speed: 60 },
{ type: "delay", ms: 300 },
{ type: "typewriter", text: "USE THE LIGHT TO FIND THEM.", speed: 60 },
"",
"The cursor dims slightly, as if tired from speaking.",
],
next: "start",
delay: 1500,
},
"code-cave": {
title: "The Code Cave",
content: [
"Your flashlight illuminates a cavern of glowing text.",
"Ancient code scrolls across the walls.",
"",
{
type: "ascii",
art: `
function mystery() {
return ??????;
}
`,
className: "info",
},
"",
{
condition: { not: { path: "visited.code-cave" } },
content: "This is your first time in the code cave.",
},
{
condition: { path: "visited.code-cave" },
content: "The familiar glow of code surrounds you.",
},
],
onEnter: [{ set: "visited.code-cave", value: true }],
prompt: "What do you do?",
options: [
{ text: "Examine the code", next: "examine-code" },
{
text: "Search for treasure",
condition: { not: { path: "inventory", contains: "key" } },
next: "find-key",
},
{
text: "Use the mysterious key",
condition: { path: "inventory", contains: "key" },
next: "use-key",
},
{ text: "Return to the terminal", next: "start" },
],
},
"examine-code": {
content: [
"You study the ancient code...",
{ type: "delay", ms: 500 },
"",
"It seems to be a function that returns...",
{ type: "delay", ms: 800 },
{ text: "...the meaning of everything?", className: "warning" },
"",
"How curious.",
],
next: "code-cave",
delay: 3000,
},
"find-key": {
content: [
"You search among the glowing symbols...",
{ type: "delay", ms: 800 },
"",
"Behind a cascade of semicolons, you find something!",
],
onEnter: [
{ addItem: "key" },
{ printSuccess: "You found a mysterious key!" },
],
next: "code-cave",
delay: 1000,
},
"use-key": {
title: "The Hidden Chamber",
content: [
"The key fits into a slot hidden in the code.",
{ type: "delay", ms: 500 },
"",
"A hidden chamber opens before you!",
{ type: "delay", ms: 500 },
"",
{
type: "ascii",
art: `
CONGRATULATIONS!
You found the secret
of the Terminal Quest!
The treasure is:
KNOWLEDGE
`,
className: "success",
},
"",
{ text: "Thanks for playing this test adventure!", className: "info" },
"",
'Type "quest" to play again, or "quest reset" to start fresh.',
],
onEnter: [
{ increment: "gold", amount: 100 },
{ printSuccess: "You also found 100 gold!" },
{ set: "completed", value: true },
],
},
},
};
// Register the game when terminal is available
if (window.terminal && window.GameEngine) {
const game = new GameEngine(testAdventureGame);
game.register();
}

View file

@ -97,3 +97,58 @@
.hidden {
display: none;
}
// Game Engine Styles
.game-title {
font-size: 1.2em;
font-weight: bold;
color: #44ff44;
text-shadow: 0 0 10px rgba(68, 255, 68, 0.5);
}
.game-scene-title {
font-weight: bold;
color: #00ffff;
border-bottom: 1px solid currentColor;
display: inline-block;
padding-bottom: 2px;
margin-bottom: 0.5em;
}
.game-options {
margin: 0.5em 0;
}
.game-option {
padding: 2px 0;
color: #888;
}
.game-option-selected {
padding: 2px 0;
color: #44ff44;
font-weight: bold;
}
.game-ascii {
color: #44ff44;
line-height: 1.1;
}
.game-ansi-art {
line-height: 1;
font-size: 12px;
margin: 0;
padding: 0;
white-space: pre;
font-family: monospace;
// Ensure spans don't add extra space
span {
display: inline;
}
}
.typewriter-line {
display: inline;
}