refactor for shared states and multi chapters

This commit is contained in:
Dan 2026-01-22 11:03:05 +00:00
parent 691b809b41
commit b9bea16f78
13 changed files with 2308 additions and 1786 deletions

View file

@ -5,12 +5,17 @@ class GameEngine {
this.terminal = null;
this.adapter = null;
this.state = null;
this.sharedState = null; // For series games
this.input = null;
this.sound = null;
this.scenes = null;
this.isRunning = false;
this.originalExecuteCommand = null;
// Series support
this.seriesId = gameDefinition.seriesId || null;
this.chapterNumber = gameDefinition.chapterNumber || null;
}
// Register this game as a terminal command
@ -35,6 +40,10 @@ class GameEngine {
self._reset();
return;
}
if (args[0] === "reset-series" && self.seriesId) {
self._resetSeries();
return;
}
if (args[0] === "continue" || args[0] === "resume") {
await self.start(true);
return;
@ -51,6 +60,23 @@ class GameEngine {
return;
}
// Initialize shared state for series games (do this early for chapter check)
if (this.seriesId && window.SharedStateManager) {
this.sharedState = new SharedStateManager(this.seriesId);
this.sharedState.init(this.definition.sharedStateDefaults || {});
// Check if this chapter can be played (sequential unlock)
if (this.chapterNumber && this.chapterNumber > 1) {
if (!this.sharedState.canPlayChapter(this.chapterNumber)) {
const prevChapter = this.chapterNumber - 1;
this.terminal.printError(
`You must complete Chapter ${prevChapter} before playing Chapter ${this.chapterNumber}.`,
);
return;
}
}
}
// Update the global HTML to add the game in progress details
document.body.classList.add("game-in-progress");
console.log(document.body.classList);
@ -59,7 +85,7 @@ class GameEngine {
// Initialize components
this.adapter = new TerminalAdapter(this.terminal);
this.state = new StateManager(this.definition.id);
this.state = new StateManager(this.definition.id, this.sharedState);
this.input = new InputManager(this.adapter);
// Initialize sound manager if SoundManager is available
@ -77,8 +103,11 @@ class GameEngine {
// Initialize state
this.state.init(this.definition.initialState || {});
// Load and merge external scenes if defined
const mergedScenes = this._getMergedScenes();
// Register scenes
this.scenes.registerScenes(this.definition.scenes);
this.scenes.registerScenes(mergedScenes);
// Hook into terminal input
this._hookInput();
@ -236,6 +265,66 @@ class GameEngine {
);
}
// Reset entire series progress (for series games)
_resetSeries() {
// Reset chapter state
this._reset();
// Reset shared state
if (this.seriesId) {
const sharedState = new SharedStateManager(this.seriesId);
sharedState.reset();
this.terminal.printSuccess(
`All ${this.definition.name} series progress has been reset.`,
);
}
}
// Create context for scene factories
_createSceneContext() {
return {
chapterNumber: this.chapterNumber || 1,
seriesId: this.seriesId,
sharedState: this.sharedState,
art: this.definition.art || {},
additionalOptions: this.definition.additionalHubOptions || [],
};
}
// Load external scenes and merge with inline scenes
_getMergedScenes() {
const inlineScenes = this.definition.scenes || {};
// If no external scenes defined, just return inline scenes
if (
!this.definition.externalScenes ||
this.definition.externalScenes.length === 0
) {
return inlineScenes;
}
// Load external scenes from registered factories
const externalScenes = {};
const context = this._createSceneContext();
for (const sceneRef of this.definition.externalScenes) {
const factory = window.SceneFactories?.[sceneRef];
if (factory) {
try {
const scenes = factory(context);
Object.assign(externalScenes, scenes);
} catch (e) {
console.error(`Failed to load external scenes from ${sceneRef}:`, e);
}
} else {
console.warn(`Scene factory not found: ${sceneRef}`);
}
}
// Inline scenes override external scenes (allows chapter-specific overrides)
return { ...externalScenes, ...inlineScenes };
}
// Debug command to skip to a specific scene
async _debugGoToScene(sceneName) {
const scene = this.scenes.getScene(sceneName);

View file

@ -322,6 +322,19 @@ class SceneManager {
this.state.set(action.set, action.value);
}
// Set value in shared state (for series games)
if (action.setShared !== undefined) {
this.state.setShared(action.setShared, action.value);
}
// Mark a chapter as complete in shared state
if (action.markChapterComplete !== undefined) {
const sharedState = this.state.getSharedState();
if (sharedState) {
sharedState.markChapterComplete(action.markChapterComplete);
}
}
if (action.increment !== undefined) {
this.state.increment(action.increment, action.amount || 1);
}

View file

@ -0,0 +1,66 @@
// Scene Registry - Global registry for shared scene factories
// Scene factories are functions that take a context and return scene definitions
// Initialize global scene factories object
window.SceneFactories = window.SceneFactories || {};
// Helper class for working with scene factories
class SceneRegistry {
// Register a scene factory
static register(id, factory) {
if (typeof factory !== "function") {
console.error(`Scene factory must be a function: ${id}`);
return false;
}
window.SceneFactories[id] = factory;
return true;
}
// Get a scene factory
static get(id) {
return window.SceneFactories[id] || null;
}
// Check if a factory exists
static has(id) {
return id in window.SceneFactories;
}
// List all registered factories
static list() {
return Object.keys(window.SceneFactories);
}
// Unregister a factory
static unregister(id) {
delete window.SceneFactories[id];
}
// Create scenes from a factory with context
static createScenes(id, context) {
const factory = this.get(id);
if (!factory) {
console.warn(`Scene factory not found: ${id}`);
return {};
}
try {
return factory(context);
} catch (e) {
console.error(`Error creating scenes from factory ${id}:`, e);
return {};
}
}
// Merge multiple factories into one scene object
static mergeFactories(factoryIds, context) {
const merged = {};
for (const id of factoryIds) {
const scenes = this.createScenes(id, context);
Object.assign(merged, scenes);
}
return merged;
}
}
// Make available globally
window.SceneRegistry = SceneRegistry;

View file

@ -0,0 +1,190 @@
// Shared State Manager - Manages game state across multiple chapters in a series
class SharedStateManager {
constructor(seriesId) {
this.seriesId = seriesId;
this.state = {};
this.storageKey = `series_${seriesId}_state`;
}
// Initialize with default state (only sets values not already in storage)
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);
}
// Check if a specific chapter is complete
isChapterComplete(chapterNumber) {
const completed = this.get("chapters_completed", []);
return completed.includes(chapterNumber);
}
// Mark a chapter as complete
markChapterComplete(chapterNumber) {
this.addToArray("chapters_completed", chapterNumber);
}
// Check if chapter can be played (previous chapters complete)
canPlayChapter(chapterNumber) {
if (chapterNumber <= 1) return true;
return this.isChapterComplete(chapterNumber - 1);
}
// Get the highest completed chapter number
getHighestCompletedChapter() {
const completed = this.get("chapters_completed", []);
return completed.length > 0 ? Math.max(...completed) : 0;
}
// Get entire state (for debugging)
getAll() {
return this._deepClone(this.state);
}
// Reset all series state
reset() {
this.state = {};
try {
localStorage.removeItem(this.storageKey);
} catch (e) {
console.warn("Failed to clear series state from storage:", e);
}
}
// Check if there is saved state
hasSavedState() {
try {
return localStorage.getItem(this.storageKey) !== null;
} catch (e) {
return false;
}
}
// Export state for debugging or backup
exportState() {
return JSON.stringify(this.state, null, 2);
}
// Import state from backup
importState(jsonString) {
try {
const data = JSON.parse(jsonString);
this.state = data;
this._saveToStorage();
return true;
} catch (e) {
console.error("Failed to import state:", e);
return false;
}
}
_saveToStorage() {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.state));
} catch (e) {
console.warn("Failed to save series 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 series 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;
}
}
// Make available globally
window.SharedStateManager = SharedStateManager;

View file

@ -1,7 +1,8 @@
// State Manager - Manages game state with persistence and conditions
class StateManager {
constructor(gameId) {
constructor(gameId, sharedState = null) {
this.gameId = gameId;
this.sharedState = sharedState; // Optional SharedStateManager for series games
this.state = {};
this.storageKey = `game_${gameId}_state`;
}
@ -13,18 +14,38 @@ class StateManager {
}
// Get value using dot notation path (e.g., "inventory.sword")
// Checks local state first, then falls back to shared state if available
get(path, defaultValue = undefined) {
// First check local state
const localValue = this._getFromState(this.state, path);
if (localValue !== undefined) {
return localValue;
}
// Fall back to shared state if available
if (this.sharedState) {
const sharedValue = this.sharedState.get(path);
if (sharedValue !== undefined) {
return sharedValue;
}
}
return defaultValue;
}
// Internal helper to get value from a specific state object
_getFromState(stateObj, path) {
const parts = path.split(".");
let current = this.state;
let current = stateObj;
for (const part of parts) {
if (current === undefined || current === null) {
return defaultValue;
return undefined;
}
current = current[part];
}
return current !== undefined ? current : defaultValue;
return current;
}
// Set value using dot notation path
@ -80,6 +101,25 @@ class StateManager {
return arr.includes(item);
}
// Set value in shared state (for series games)
setShared(path, value) {
if (!this.sharedState) {
console.warn("No shared state manager - setting locally instead");
return this.set(path, value);
}
this.sharedState.set(path, value);
}
// Check if path exists in shared state
hasShared(path) {
return this.sharedState?.get(path) !== undefined;
}
// Get reference to shared state manager
getSharedState() {
return this.sharedState;
}
// Evaluate a condition against current state
evaluate(condition) {
if (typeof condition === "boolean") {