Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+144 -2
View File
@@ -89,6 +89,14 @@ class AudioManagerModule extends BaseModule {
this.handleMediaBlock(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail || {});
});
this.addEventListener(document, 'game:config', (event) => {
this.applyGameConfig(event.detail || {});
});
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'audio') {
@@ -131,6 +139,15 @@ class AudioManagerModule extends BaseModule {
document.addEventListener('pointerdown', unlock, { passive: true });
document.addEventListener('keydown', unlock);
}
applyGameConfig(config) {
const assets = config?.assets || {};
this.assetRoots = {
images: assets.images || this.assetRoots.images,
music: assets.music || this.assetRoots.music,
sounds: assets.sounds || assets.sfx || this.assetRoots.sounds
};
}
/**
* Set up Web Audio API context if needed
@@ -438,7 +455,7 @@ class AudioManagerModule extends BaseModule {
}
if (cue.type === 'sfx') {
this.playSfx(cue.filename);
this.playSfx(cue.filename, cue);
} else if (cue.type === 'music') {
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
}
@@ -452,18 +469,122 @@ class AudioManagerModule extends BaseModule {
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
}
async playSfx(filename) {
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
const filename = String(tag?.value || tag?.filename || '').trim();
if (!key || !filename) {
return;
}
if (key === 'sfx' || key === 'sound' || key === 'audio') {
this.playSfx(filename, this.parseSfxTagOptions(tag.param || tag.options || ''));
return;
}
if (key === 'music') {
const options = this.parseMusicTagOptions(tag.param || tag.options || '');
this.playMusic(filename, options.mode, { loop: options.loop });
}
}
parseMusicTagOptions(optionText) {
const options = {
mode: 'crossfade',
loop: true
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['queue', 'crossfade', 'cut'].includes(token)) {
options.mode = token;
} else if (['loop', 'looped', 'repeat'].includes(token)) {
options.loop = true;
} else if (['once', 'single', 'no-loop', 'noloop'].includes(token)) {
options.loop = false;
} else if (key === 'loop') {
options.loop = !['false', '0', 'no', 'once'].includes(value);
} else if (key === 'mode' && ['queue', 'crossfade', 'cut'].includes(value)) {
options.mode = value;
}
});
return options;
}
parseSfxTagOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(token)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(token)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(token)) {
options.maxDurationSeconds = Number(token.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
async playSfx(filename, options = {}) {
try {
const template = await this.preloadSfx(filename);
const audio = template.cloneNode(true);
audio.volume = this.getSfxVolume();
this.currentAudio = audio;
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000);
let maxTimer = null;
audio.addEventListener('ended', () => {
if (maxTimer) clearTimeout(maxTimer);
if (this.currentAudio === audio) {
this.currentAudio = null;
}
}, { once: true });
await audio.play();
if (maxDuration > 0) {
const timeoutDuration = endMode === 'fade'
? Math.max(0, maxDuration - fadeDuration)
: maxDuration;
maxTimer = setTimeout(() => {
if (audio.paused || audio.ended) return;
if (endMode === 'fade') {
console.log(`AudioManager: Fading sound effect ${filename} over ${fadeDuration}ms`);
this.fadeOutAudio(audio, fadeDuration);
} else {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
}
}, timeoutDuration);
}
console.log(`AudioManager: Playing sound effect ${filename}`);
return audio;
} catch (error) {
@@ -472,6 +593,27 @@ class AudioManagerModule extends BaseModule {
}
}
fadeOutAudio(audio, duration = 1000) {
if (!audio) return Promise.resolve(false);
const startVolume = audio.volume;
const startedAt = performance.now();
return new Promise(resolve => {
const step = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
if (progress < 1 && !audio.paused && !audio.ended) {
requestAnimationFrame(step);
return;
}
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
resolve(true);
};
requestAnimationFrame(step);
});
}
async playMusic(filename, mode = 'crossfade', options = {}) {
const url = this.getAssetUrl('music', filename);
const shouldLoop = options.loop !== false;