all kinds of updates
This commit is contained in:
parent
25815aa4d2
commit
5f2bbca38f
10 changed files with 792 additions and 216 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
247
assets/js/games/engine/sound-manager.js
Normal file
247
assets/js/games/engine/sound-manager.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue