Add ink integration UI and media playback
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user