game engine start
This commit is contained in:
parent
d0c65a71ad
commit
d5510eb989
10 changed files with 3074 additions and 1 deletions
212
assets/js/games/engine/ansi-converter.js
Normal file
212
assets/js/games/engine/ansi-converter.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
},
|
||||
|
||||
// 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;
|
||||
}
|
||||
210
assets/js/games/engine/game-engine.js
Normal file
210
assets/js/games/engine/game-engine.js
Normal 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;
|
||||
205
assets/js/games/engine/input-manager.js
Normal file
205
assets/js/games/engine/input-manager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
367
assets/js/games/engine/scene-manager.js
Normal file
367
assets/js/games/engine/scene-manager.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
208
assets/js/games/engine/state-manager.js
Normal file
208
assets/js/games/engine/state-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
70
assets/js/games/engine/terminal-adapter.js
Normal file
70
assets/js/games/engine/terminal-adapter.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1457
assets/js/games/games/boxing-day.js
Normal file
1457
assets/js/games/games/boxing-day.js
Normal file
File diff suppressed because it is too large
Load diff
281
assets/js/games/games/test-adventure.js
Normal file
281
assets/js/games/games/test-adventure.js
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue