all kinds of updates

This commit is contained in:
Dan 2026-01-20 14:31:02 +00:00
parent 25815aa4d2
commit 5f2bbca38f
10 changed files with 792 additions and 216 deletions

View file

@ -6,6 +6,7 @@ class GameEngine {
this.adapter = null;
this.state = null;
this.input = null;
this.sound = null;
this.scenes = null;
this.isRunning = false;
@ -53,7 +54,13 @@ class GameEngine {
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 sound manager if SoundManager is available
if (window.SoundManager) {
this.sound = new SoundManager(this.adapter);
}
this.scenes = new SceneManager(this.adapter, this.state, this.input, this.sound);
// Initialize state
this.state.init(this.definition.initialState || {});
@ -173,6 +180,11 @@ class GameEngine {
this.isRunning = false;
// Cleanup sound manager
if (this.sound) {
this.sound.stopAll();
}
// Cleanup input manager
if (this.input) {
this.input.destroy();

View file

@ -18,7 +18,8 @@ class InputManager {
// Check if terminal input has text - if so, let user submit commands like "quit"
const terminalInput = document.getElementById("input");
const hasInputText = terminalInput && terminalInput.value.trim().length > 0;
const hasInputText =
terminalInput && terminalInput.value.trim().length > 0;
if (e.key === "ArrowUp") {
e.preventDefault();
@ -85,6 +86,7 @@ class InputManager {
if (prompt) {
this.adapter.print("");
this.adapter.printInfo("------------------");
this.adapter.printInfo(prompt);
}
this.adapter.print("");

View file

@ -1,12 +1,14 @@
// Scene Manager - Handles scene definitions, rendering, and transitions
class SceneManager {
constructor(adapter, stateManager, inputManager) {
constructor(adapter, stateManager, inputManager, soundManager = null) {
this.adapter = adapter;
this.state = stateManager;
this.input = inputManager;
this.sound = soundManager;
this.scenes = {};
this.currentScene = null;
this.sceneHistory = [];
this.activeSounds = new Map(); // Track sounds started in current scene
}
// Register scenes from game definition
@ -34,8 +36,16 @@ class SceneManager {
this.sceneHistory.push(this.currentScene.id);
}
// Stop scene-specific sounds from previous scene
await this._cleanupSceneSounds();
this.currentScene = scene;
// Preload sounds for this scene
if (this.sound && scene.sounds) {
await this._preloadSceneSounds(scene.sounds);
}
// Execute onEnter actions
if (scene.onEnter) {
await this._executeActions(scene.onEnter);
@ -145,7 +155,11 @@ class SceneManager {
}
if (block.type === "typewriter") {
await this._typewriter(block.text, block.speed || 50);
await this._typewriter(block.text, block.speed || 50, {
bold: block.bold,
italic: block.italic,
className: block.className,
});
continue;
}
@ -154,12 +168,20 @@ class SceneManager {
continue;
}
if (block.type === "sound") {
await this._handleSound(block);
continue;
}
// Text with optional className (supports html: true for HTML content)
if (block.text !== undefined) {
if (block.html) {
this._printHTML(block.text, block.className || "");
} else {
this._printText(block.text, block.className || "");
this._printText(block.text, block.className || "", {
bold: block.bold,
italic: block.italic,
});
}
continue;
}
@ -167,15 +189,22 @@ class SceneManager {
}
// Print text with variable interpolation
_printText(text, className = "") {
_printText(text, className = "", options = {}) {
// 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);
// Build style classes based on options
let styleClasses = className;
if (options.bold)
styleClasses += (styleClasses ? " " : "") + "typewriter-bold";
if (options.italic)
styleClasses += (styleClasses ? " " : "") + "typewriter-italic";
if (styleClasses) {
this.adapter.print(interpolated, styleClasses);
} else {
this.adapter.print(interpolated);
}
@ -190,7 +219,9 @@ class SceneManager {
});
if (className) {
this.adapter.printHTML(`<span class="${className}">${interpolated}</span>`);
this.adapter.printHTML(
`<span class="${className}">${interpolated}</span>`,
);
} else {
this.adapter.printHTML(interpolated);
}
@ -350,10 +381,16 @@ class SceneManager {
}
// Typewriter effect
async _typewriter(text, speed) {
async _typewriter(text, speed, options = {}) {
const interpolated = this._interpolateText(text);
let output = "";
// Build style classes based on options
let styleClasses = "typewriter-line";
if (options.bold) styleClasses += " typewriter-bold";
if (options.italic) styleClasses += " typewriter-italic";
if (options.className) styleClasses += " " + options.className;
for (const char of interpolated) {
output += char;
// Create a single updating line for typewriter
@ -364,7 +401,7 @@ class SceneManager {
typewriterSpan.textContent = output;
} else {
this.adapter.printHTML(
`<span class="typewriter-line">${output}</span>`,
`<span class="${styleClasses}">${output}</span>`,
);
}
@ -451,4 +488,127 @@ class SceneManager {
resetHistory() {
this.sceneHistory = [];
}
// Preload sounds for a scene
async _preloadSceneSounds(sounds) {
if (!this.sound) return;
const soundList = Array.isArray(sounds) ? sounds : [sounds];
let hasShownLoading = false;
for (const soundDef of soundList) {
const soundId = soundDef.id;
const url = soundDef.url || soundDef.src;
if (!soundId || !url) {
console.warn("Invalid sound definition:", soundDef);
continue;
}
// Skip if already loaded
if (this.sound.isLoaded(soundId)) {
continue;
}
// Show loading indicator if not shown yet
if (!hasShownLoading) {
this.adapter.printHTML(
'<span class="sound-loading info">Loading audio...</span>',
);
hasShownLoading = true;
}
try {
await this.sound.preload(soundId, url);
} catch (error) {
console.error(`Failed to preload sound ${soundId}:`, error);
// Continue loading other sounds even if one fails
}
}
// Remove loading indicator
if (hasShownLoading) {
const indicator =
this.adapter.terminal.output.querySelector(".sound-loading");
if (indicator) {
indicator.remove();
}
}
}
// Handle sound playback in content blocks
async _handleSound(block) {
if (!this.sound) {
console.warn("Sound manager not available");
return;
}
const action = block.action || "play"; // play, stop, stopAll
const soundId = block.id || block.sound;
try {
if (action === "play") {
const options = {
loop: block.loop || false,
volume: block.volume !== undefined ? block.volume : 1.0,
fade: block.fade || false,
fadeDuration: block.fadeDuration || 1000,
};
const controller = await this.sound.play(soundId, options);
// Store reference for cleanup unless it's a one-shot sound
if (block.loop || block.persist) {
this.activeSounds.set(soundId, controller);
}
// Auto-stop after duration if specified
if (block.duration) {
setTimeout(() => {
if (block.fadeOut !== false) {
controller.fadeOut(block.fadeDuration || 1000);
} else {
controller.stop();
}
}, block.duration);
}
} else if (action === "stop") {
const controller = this.activeSounds.get(soundId);
if (controller) {
if (block.fadeOut !== false) {
await controller.fadeOut(block.fadeDuration || 1000);
} else {
controller.stop();
}
this.activeSounds.delete(soundId);
}
} else if (action === "stopAll") {
await this._cleanupSceneSounds();
}
} catch (error) {
console.error(`Sound error (${action} ${soundId}):`, error);
// Don't show error to user, just log it
}
}
// Clean up sounds when leaving a scene
async _cleanupSceneSounds() {
if (!this.sound) return;
const fadePromises = [];
for (const [, controller] of this.activeSounds) {
if (controller.fadeOut) {
fadePromises.push(
controller.fadeOut(500).catch((e) => console.error("Fade error:", e)),
);
} else {
controller.stop();
}
}
// Wait for all fades to complete
await Promise.all(fadePromises);
this.activeSounds.clear();
}
}

View file

@ -0,0 +1,247 @@
// Sound Manager - Handles audio loading, caching, and playback for games
class SoundManager {
constructor(adapter) {
this.adapter = adapter;
this.sounds = new Map(); // soundId -> { audio, loaded, loading, error }
this.currentlyPlaying = new Set(); // Track currently playing sounds
this.globalVolume = 1.0;
}
// Preload a sound file
async preload(soundId, url) {
// If already loaded or loading, return existing promise
if (this.sounds.has(soundId)) {
const sound = this.sounds.get(soundId);
if (sound.loaded) {
return sound.audio;
}
if (sound.loading) {
return sound.loadingPromise;
}
if (sound.error) {
throw new Error(`Sound ${soundId} failed to load: ${sound.error}`);
}
}
// Create new audio element
const audio = new Audio();
const soundEntry = {
audio,
loaded: false,
loading: true,
error: null,
url,
};
// Create promise for loading
const loadingPromise = new Promise((resolve, reject) => {
const onLoad = () => {
soundEntry.loaded = true;
soundEntry.loading = false;
audio.removeEventListener("canplaythrough", onLoad);
audio.removeEventListener("error", onError);
resolve(audio);
};
const onError = (e) => {
soundEntry.loading = false;
soundEntry.error = e.message || "Failed to load audio";
audio.removeEventListener("canplaythrough", onLoad);
audio.removeEventListener("error", onError);
reject(new Error(`Failed to load sound ${soundId}: ${soundEntry.error}`));
};
audio.addEventListener("canplaythrough", onLoad, { once: true });
audio.addEventListener("error", onError, { once: true });
audio.preload = "auto";
audio.src = url;
audio.load();
});
soundEntry.loadingPromise = loadingPromise;
this.sounds.set(soundId, soundEntry);
return loadingPromise;
}
// Play a sound (will load if not already loaded)
async play(soundId, options = {}) {
const {
loop = false,
volume = 1.0,
onEnd = null,
fade = false,
fadeDuration = 1000,
} = options;
let soundEntry = this.sounds.get(soundId);
if (!soundEntry) {
throw new Error(`Sound ${soundId} not preloaded. Use preload() first.`);
}
// Wait for loading if still loading
if (soundEntry.loading) {
await soundEntry.loadingPromise;
}
if (soundEntry.error) {
throw new Error(`Sound ${soundId} failed to load: ${soundEntry.error}`);
}
const audio = soundEntry.audio;
// Clone the audio element for concurrent playback
const playInstance = audio.cloneNode();
playInstance.loop = loop;
playInstance.volume = fade ? 0 : volume * this.globalVolume;
// Track this instance
const trackingId = `${soundId}_${Date.now()}`;
this.currentlyPlaying.add(trackingId);
// Handle end event
const cleanup = () => {
this.currentlyPlaying.delete(trackingId);
playInstance.removeEventListener("ended", cleanup);
if (onEnd) onEnd();
};
playInstance.addEventListener("ended", cleanup);
// Play the sound
try {
await playInstance.play();
// Fade in if requested
if (fade) {
this._fadeIn(playInstance, volume * this.globalVolume, fadeDuration);
}
return {
instance: playInstance,
stop: () => {
playInstance.pause();
playInstance.currentTime = 0;
cleanup();
},
fadeOut: (duration = fadeDuration) => {
return this._fadeOut(playInstance, duration).then(() => {
playInstance.pause();
cleanup();
});
},
};
} catch (error) {
cleanup();
throw new Error(`Failed to play sound ${soundId}: ${error.message}`);
}
}
// Fade in audio
_fadeIn(audio, targetVolume, duration) {
const steps = 20;
const stepDuration = duration / steps;
const volumeStep = targetVolume / steps;
let currentStep = 0;
const interval = setInterval(() => {
currentStep++;
audio.volume = Math.min(volumeStep * currentStep, targetVolume);
if (currentStep >= steps) {
clearInterval(interval);
audio.volume = targetVolume;
}
}, stepDuration);
}
// Fade out audio
_fadeOut(audio, duration) {
return new Promise((resolve) => {
const steps = 20;
const stepDuration = duration / steps;
const startVolume = audio.volume;
const volumeStep = startVolume / steps;
let currentStep = 0;
const interval = setInterval(() => {
currentStep++;
audio.volume = Math.max(startVolume - volumeStep * currentStep, 0);
if (currentStep >= steps) {
clearInterval(interval);
audio.volume = 0;
resolve();
}
}, stepDuration);
});
}
// Stop all currently playing sounds
stopAll() {
for (const soundId of Array.from(this.currentlyPlaying)) {
const [id] = soundId.split("_");
const soundEntry = this.sounds.get(id);
if (soundEntry && soundEntry.audio) {
soundEntry.audio.pause();
soundEntry.audio.currentTime = 0;
}
}
this.currentlyPlaying.clear();
}
// Set global volume (0.0 to 1.0)
setGlobalVolume(volume) {
this.globalVolume = Math.max(0, Math.min(1, volume));
}
// Check if a sound is loaded
isLoaded(soundId) {
const sound = this.sounds.get(soundId);
return sound && sound.loaded;
}
// Check if a sound is currently loading
isLoading(soundId) {
const sound = this.sounds.get(soundId);
return sound && sound.loading;
}
// Get loading progress for all sounds
getLoadingStatus() {
const status = {
total: this.sounds.size,
loaded: 0,
loading: 0,
failed: 0,
};
for (const [, sound] of this.sounds) {
if (sound.loaded) status.loaded++;
else if (sound.loading) status.loading++;
else if (sound.error) status.failed++;
}
return status;
}
// Clear all sounds (useful for cleanup)
clear() {
this.stopAll();
this.sounds.clear();
}
// Remove a specific sound from cache
unload(soundId) {
const sound = this.sounds.get(soundId);
if (sound && sound.audio) {
sound.audio.pause();
sound.audio.src = "";
}
this.sounds.delete(soundId);
}
}
// Make available globally
window.SoundManager = SoundManager;