Add ink integration UI and media playback
@@ -421,6 +421,54 @@ ol.choice {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.story-image-block {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.story-image-block img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
mix-blend-mode: multiply;
|
||||
filter: contrast(1.05);
|
||||
}
|
||||
|
||||
.story-image-landscape,
|
||||
.story-image-square {
|
||||
clear: both;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.story-image-portrait {
|
||||
float: left;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
shape-outside: inset(0);
|
||||
}
|
||||
|
||||
.story-image-portrait.story-image-float-right {
|
||||
float: right;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.story-image-pending img {
|
||||
opacity: 0;
|
||||
clip-path: polygon(0 0, 0 0, 0 0);
|
||||
}
|
||||
|
||||
.story-image-visible img {
|
||||
opacity: 1;
|
||||
clip-path: polygon(0 0, 220% 0, 0 220%);
|
||||
transition: opacity 900ms ease, clip-path 900ms ease;
|
||||
}
|
||||
|
||||
/* #story p span {
|
||||
font-feature-settings: 'kern' on, 'liga' on, 'onum' on, 'clig' on, 'hlig' on;
|
||||
} */
|
||||
@@ -547,10 +595,22 @@ ol.choice {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
bottom: 0.65rem;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
background-color: transparent;
|
||||
line-height: 1.1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#remark_hint {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
#game_legal {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
#lighting {
|
||||
@@ -596,6 +656,17 @@ body:not([data-game-running="true"]) #command_history {
|
||||
#command_history .history-item {
|
||||
margin-bottom: 0.25rem;
|
||||
color: rgba(0, 0, 0, 0.82);
|
||||
cursor: pointer;
|
||||
transition: color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
#command_history .history-item:hover,
|
||||
#command_history .history-item.active {
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
#command_history .history-item.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#command_history::-webkit-scrollbar {
|
||||
@@ -633,6 +704,76 @@ body:not([data-game-running="true"]) #command_history {
|
||||
pointer-events: none; /* Prevent interaction while faded out */
|
||||
}
|
||||
|
||||
.story-choices {
|
||||
width: 100%;
|
||||
margin: 0 0 1rem 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.45s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.story-choices[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-process-state="waiting-generating"] .story-choices,
|
||||
html[data-process-state="playing-generating"] .story-choices,
|
||||
html[data-process-state="playing-ready"] .story-choices,
|
||||
html[data-process-state="command-waiting"] .story-choices {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html[data-process-state="ready"] .story-choices[data-choice-ready="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.choice-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.choice-list-item {
|
||||
margin: 0 0 0.45rem 0;
|
||||
}
|
||||
|
||||
.choice-list .choice-button {
|
||||
display: grid;
|
||||
grid-template-columns: 1.8em 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
padding: 0.15rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(37, 31, 24, 0.72);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.choice-list .choice-button:hover,
|
||||
.choice-list .choice-button:focus-visible {
|
||||
color: rgba(0, 0, 0, 0.96);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.choice-list kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.4em;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Input wrapper for positioning cursor */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
@@ -745,15 +886,18 @@ html[data-process-state="playing-ready"] * {
|
||||
#story p.story-chapter-heading {
|
||||
position: relative;
|
||||
height: auto;
|
||||
margin: 0 0 1.45em 0;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#story p.story-textblock-start {
|
||||
margin-top: 1.45em;
|
||||
#story p.story-section-heading {
|
||||
position: relative;
|
||||
height: auto;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Typography for word elements in rendered paragraphs */
|
||||
|
||||
@@ -6,14 +6,15 @@ Story image paths resolve relative to this directory.
|
||||
Image block markup:
|
||||
|
||||
```text
|
||||
::image[widescreen](image-name.jpg)
|
||||
::image[portrait](image-name.jpg)
|
||||
#image[image-name.jpg](landscape)
|
||||
#image[image-name.jpg](portrait)
|
||||
```
|
||||
|
||||
Sizes:
|
||||
|
||||
- `widescreen`: exactly 100% of the page width and 50% of the page height.
|
||||
- `portrait`: exactly 100% of the page width and 100% of the page height.
|
||||
- `landscape`/`widescreen`: 16:9, centered, near full page width, height snapped to whole line heights.
|
||||
- `portrait`: 16:9, half page width, height snapped to whole line heights, with following prose flowing beside it.
|
||||
- `square`: 1:1, centered, near full page width, height snapped to whole line heights.
|
||||
|
||||
Image markup is parsed and queued by the story markup system, but final image rendering is still future work. Keep assets ready for that renderer by using browser-friendly formats such as `.jpg`, `.png`, `.webp`, or `.avif`.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Interactive Fiction</title>
|
||||
<title></title>
|
||||
<link rel="preload" href="/fonts/EBGaramond12-Regular.otf" as="font" type="font/otf" crossorigin>
|
||||
<link rel="preload" href="/fonts/EBGaramond12-Italic.otf" as="font" type="font/otf" crossorigin>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
@@ -297,6 +297,6 @@
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="/js/loader.js?v=20260514-new-game-click"></script>
|
||||
<script type="module" src="/js/loader.js?v=20260515-lead-kap-verified"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -310,7 +310,13 @@ export class BaseModule {
|
||||
this._trackResource(url);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
const cacheBuster = window.MODULE_CACHE_BUSTER;
|
||||
if (cacheBuster && /^\/(js|css)\//.test(url)) {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
script.src = `${url}${separator}v=${encodeURIComponent(cacheBuster)}`;
|
||||
} else {
|
||||
script.src = url;
|
||||
}
|
||||
if (isModule) {
|
||||
script.type = 'module';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Choice Display Module
|
||||
* Renders choice-mode interactions from TurnResult choices.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class ChoiceDisplayModule extends BaseModule {
|
||||
constructor() {
|
||||
super('choice-display', 'Choice Display');
|
||||
|
||||
this.dependencies = ['socket-client'];
|
||||
this.socketClient = null;
|
||||
this.container = null;
|
||||
this.choices = [];
|
||||
this.inputMode = 'text';
|
||||
this.processState = document.documentElement.dataset.processState || 'ready';
|
||||
this.template = {
|
||||
cells: {
|
||||
default: {
|
||||
label: '',
|
||||
match: () => true
|
||||
}
|
||||
},
|
||||
fallbackCell: 'default'
|
||||
};
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'setupContainer',
|
||||
'handleChoices',
|
||||
'handleInputMode',
|
||||
'handleProcessState',
|
||||
'handleKeyDown',
|
||||
'render',
|
||||
'clear',
|
||||
'normalizeChoices',
|
||||
'assignLetters',
|
||||
'selectChoice',
|
||||
'getTagValue',
|
||||
'getTemplateCell'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
this.setupContainer();
|
||||
|
||||
this.addEventListener(document, 'story:choices', (event) => {
|
||||
this.handleChoices(event.detail || []);
|
||||
});
|
||||
this.addEventListener(document, 'story:input-mode', (event) => {
|
||||
this.handleInputMode(event.detail || 'text');
|
||||
});
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
this.handleProcessState(event.detail?.state || 'ready');
|
||||
});
|
||||
this.addEventListener(document, 'keydown', this.handleKeyDown);
|
||||
|
||||
this.reportProgress(100, 'Choice display ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
const choicesRoot = document.getElementById('choices');
|
||||
if (!choicesRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.container = document.getElementById('story_choices');
|
||||
if (!this.container) {
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'story_choices';
|
||||
this.container.className = 'story-choices';
|
||||
}
|
||||
|
||||
const commandInput = document.getElementById('command_input');
|
||||
if (this.container.parentElement !== choicesRoot) {
|
||||
choicesRoot.insertBefore(this.container, commandInput || null);
|
||||
} else if (commandInput && this.container.nextElementSibling !== commandInput) {
|
||||
choicesRoot.insertBefore(this.container, commandInput);
|
||||
} else if (!commandInput && this.container !== choicesRoot.lastElementChild) {
|
||||
choicesRoot.appendChild(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
handleChoices(choices) {
|
||||
this.choices = this.normalizeChoices(Array.isArray(choices) ? choices : []);
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleInputMode(inputMode) {
|
||||
this.inputMode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleProcessState(state) {
|
||||
this.processState = state || 'ready';
|
||||
this.render();
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
if (this.inputMode !== 'choice' || !this.choices.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsModal = document.getElementById('options-modal');
|
||||
if (optionsModal && optionsModal.style.display !== 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const letter = event.key.toUpperCase();
|
||||
const choice = this.choices.find((item) => item.letter === letter);
|
||||
if (!choice) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.selectChoice(choice.index);
|
||||
}
|
||||
|
||||
normalizeChoices(choices) {
|
||||
return this.assignLetters(choices.slice(0, 26).map((choice, order) => {
|
||||
const tags = Array.isArray(choice.tags) ? choice.tags : [];
|
||||
const category = choice.category || this.getTagValue(tags, 'action');
|
||||
return {
|
||||
index: Number.isInteger(choice.index) ? choice.index : order,
|
||||
text: String(choice.text || ''),
|
||||
tags,
|
||||
category,
|
||||
letter: '',
|
||||
templateCell: this.getTemplateCell({ ...choice, tags, category })
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
assignLetters(choices) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const reserved = new Set();
|
||||
|
||||
choices.forEach((choice) => {
|
||||
const explicit = String(choice.letter || this.getTagValue(choice.tags, 'letter') || '')
|
||||
.trim()
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
if (alphabet.includes(explicit) && !reserved.has(explicit)) {
|
||||
choice.letter = explicit;
|
||||
reserved.add(explicit);
|
||||
}
|
||||
});
|
||||
|
||||
let nextLetterIndex = 0;
|
||||
choices.forEach((choice) => {
|
||||
if (choice.letter) return;
|
||||
while (nextLetterIndex < alphabet.length && reserved.has(alphabet[nextLetterIndex])) {
|
||||
nextLetterIndex += 1;
|
||||
}
|
||||
if (nextLetterIndex < alphabet.length) {
|
||||
choice.letter = alphabet[nextLetterIndex];
|
||||
reserved.add(choice.letter);
|
||||
nextLetterIndex += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return choices;
|
||||
}
|
||||
|
||||
getTemplateCell(choice) {
|
||||
const entries = Object.entries(this.template.cells);
|
||||
const match = entries.find(([cellName, cell]) => {
|
||||
if (cellName === this.template.fallbackCell) return false;
|
||||
return typeof cell.match === 'function' && cell.match(choice);
|
||||
});
|
||||
return match ? match[0] : this.template.fallbackCell;
|
||||
}
|
||||
|
||||
getTagValue(tags, key) {
|
||||
const normalizedKey = String(key).toLowerCase();
|
||||
const tag = tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey);
|
||||
return tag?.value;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.setupContainer();
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '';
|
||||
const readyForChoices = this.inputMode === 'choice' && this.choices.length > 0 && this.processState === 'ready';
|
||||
this.container.hidden = !readyForChoices;
|
||||
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
|
||||
if (this.container.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('ol');
|
||||
list.className = 'choice-list choice-template-default';
|
||||
|
||||
this.choices.forEach((choice) => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'choice-list-item';
|
||||
item.dataset.choiceIndex = String(choice.index);
|
||||
item.dataset.choiceLetter = choice.letter;
|
||||
item.dataset.templateCell = choice.templateCell;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'choice-button';
|
||||
button.innerHTML = `<kbd>${choice.letter}</kbd><span>${this.escapeHtml(choice.text)}</span>`;
|
||||
button.addEventListener('click', () => this.selectChoice(choice.index));
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
this.container.appendChild(list);
|
||||
}
|
||||
|
||||
async selectChoice(index) {
|
||||
if (!this.socketClient) {
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
}
|
||||
if (!this.socketClient || typeof this.socketClient.chooseChoice !== 'function') {
|
||||
console.error('ChoiceDisplay: Socket client cannot choose choices');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clear();
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
|
||||
}));
|
||||
await this.socketClient.chooseChoice(index);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.choices = [];
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.container.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
const choiceDisplay = new ChoiceDisplayModule();
|
||||
|
||||
export { choiceDisplay as ChoiceDisplay };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(choiceDisplay);
|
||||
}
|
||||
|
||||
window.ChoiceDisplay = choiceDisplay;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Game Config Module
|
||||
* Loads engine metadata, locale, and asset roots before the UI is created.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class GameConfigModule extends BaseModule {
|
||||
constructor() {
|
||||
super('game-config', 'Game Config');
|
||||
this.dependencies = ['localization', 'persistence-manager'];
|
||||
this.config = null;
|
||||
|
||||
this.bindMethods([
|
||||
'getConfig',
|
||||
'getMetadata',
|
||||
'getLocale',
|
||||
'loadConfig',
|
||||
'applyDocumentMetadata'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
this.reportProgress(20, 'Loading game configuration');
|
||||
this.config = await this.loadConfig();
|
||||
|
||||
const localization = this.getModule('localization');
|
||||
if (localization && this.config?.locale) {
|
||||
await localization.applyServerLocale(this.config.locale);
|
||||
}
|
||||
|
||||
this.applyDocumentMetadata();
|
||||
document.dispatchEvent(new CustomEvent('game:config', {
|
||||
detail: this.config
|
||||
}));
|
||||
|
||||
this.reportProgress(100, 'Game configuration ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('GameConfig: Failed to load game configuration:', error);
|
||||
this.config = {
|
||||
engine: 'unknown',
|
||||
locale: 'en_US',
|
||||
metadata: {
|
||||
title: 'AI Interactive Fiction',
|
||||
author: '',
|
||||
subtitle: '',
|
||||
version: '',
|
||||
copyright: ''
|
||||
},
|
||||
assets: {
|
||||
music: '/music/',
|
||||
sfx: '/sounds/',
|
||||
sounds: '/sounds/',
|
||||
images: '/images/'
|
||||
}
|
||||
};
|
||||
this.applyDocumentMetadata();
|
||||
document.dispatchEvent(new CustomEvent('game:config', { detail: this.config }));
|
||||
this.reportProgress(100, 'Game configuration fallback ready');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async loadConfig() {
|
||||
const response = await fetch('/api/game-config', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
applyDocumentMetadata() {
|
||||
const metadata = this.getMetadata();
|
||||
if (metadata?.title) {
|
||||
document.title = metadata.subtitle
|
||||
? `${metadata.title} - ${metadata.subtitle}`
|
||||
: metadata.title;
|
||||
}
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
getMetadata() {
|
||||
return this.config?.metadata || {};
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.config?.locale || 'en_US';
|
||||
}
|
||||
}
|
||||
|
||||
const GameConfig = new GameConfigModule();
|
||||
|
||||
export { GameConfig };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(GameConfig);
|
||||
}
|
||||
|
||||
window.GameConfig = GameConfig;
|
||||
@@ -33,8 +33,7 @@ class GameLoopModule extends BaseModule {
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
'resetClientPlaybackAndDisplay',
|
||||
'addText'
|
||||
'resetClientPlaybackAndDisplay'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -109,15 +108,6 @@ class GameLoopModule extends BaseModule {
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
// Listen for game introduction
|
||||
socketClient.on('gameIntroduction', (data) => {
|
||||
console.log("GameLoop: Received gameIntroduction");
|
||||
this.gameState.started = true;
|
||||
this.gameState.canSave = true;
|
||||
this.updateUIState();
|
||||
// Text processing is handled by socket-client -> text-buffer -> ui-controller pipeline
|
||||
});
|
||||
|
||||
socketClient.on('gameSaved', () => {
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
@@ -292,22 +282,6 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually add text to the buffer
|
||||
* Useful for testing or adding local messages
|
||||
* @param {string} text - Text to add
|
||||
*/
|
||||
addText(text) {
|
||||
// Use parent's getModule method
|
||||
const textBuffer = this.getModule('text-buffer');
|
||||
|
||||
if (!textBuffer) {
|
||||
console.warn("Text buffer not available");
|
||||
return;
|
||||
}
|
||||
|
||||
textBuffer.addText(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
|
||||
@@ -96,6 +96,15 @@ class LayoutRendererModule extends BaseModule {
|
||||
}
|
||||
|
||||
const lineHeight = lineHeightPx || parseFloat(window.getComputedStyle(paragraph).lineHeight) || 24;
|
||||
if (layoutData.role === 'chapter-heading') {
|
||||
paragraph.style.marginTop = `${lineHeight * 2}px`;
|
||||
paragraph.style.marginBottom = `${lineHeight}px`;
|
||||
} else if (layoutData.role === 'section-heading') {
|
||||
paragraph.style.marginTop = `${lineHeight}px`;
|
||||
paragraph.style.marginBottom = `${lineHeight}px`;
|
||||
} else if (layoutData.addTopSpace) {
|
||||
paragraph.style.marginTop = `${lineHeight}px`;
|
||||
}
|
||||
const maxLineWidth = Array.isArray(measures) && measures.length > 0
|
||||
? Math.max(...measures)
|
||||
: storyElement.clientWidth;
|
||||
@@ -139,7 +148,9 @@ class LayoutRendererModule extends BaseModule {
|
||||
: lineWidth;
|
||||
const lineOffset = isCentered
|
||||
? Math.max(0, (maxLineWidth - naturalLineWidth) / 2)
|
||||
: maxLineWidth - lineWidth;
|
||||
: Array.isArray(layoutData.lineOffsets)
|
||||
? (layoutData.lineOffsets[Math.min(lineIndex, layoutData.lineOffsets.length - 1)] || 0)
|
||||
: maxLineWidth - lineWidth;
|
||||
|
||||
let currentLeft = 0;
|
||||
lastChild = null;
|
||||
|
||||
@@ -24,7 +24,8 @@ const ModuleState = {
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
const MODULE_CACHE_BUSTER = '20260514-new-game-click';
|
||||
const MODULE_CACHE_BUSTER = '20260515-lead-kap-verified';
|
||||
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
||||
|
||||
/**
|
||||
* Module Loader - Manages the loading of all modules
|
||||
@@ -102,6 +103,7 @@ const ModuleLoader = (function() {
|
||||
// Core functionality modules
|
||||
{ id: 'persistence-manager', script: '/js/persistence-manager-module.js', weight: 12 },
|
||||
{ id: 'localization', script: '/js/localization-module.js', weight: 12 },
|
||||
{ id: 'game-config', script: '/js/game-config-module.js', weight: 8 },
|
||||
{ id: 'text-processor', script: '/js/text-processor-module.js', weight: 15 },
|
||||
{ id: 'markup-parser', script: '/js/markup-parser-module.js', weight: 5 },
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
|
||||
@@ -123,6 +125,7 @@ const ModuleLoader = (function() {
|
||||
{ id: 'ui-effects', script: '/js/ui-effects-module.js', weight: 12 }, // Add UI Effects module
|
||||
{ id: 'ui-input-handler', script: '/js/ui-input-handler-module.js', weight: 27 }, // Add UI Input Handler module
|
||||
{ id: 'ui-display-handler', script: '/js/ui-display-handler-module.js', weight: 27 }, // Add UI Display Handler module
|
||||
{ id: 'choice-display', script: '/js/choice-display-module.js', weight: 8 },
|
||||
{ id: 'ui-controller', script: '/js/ui-controller-module.js', weight: 27 },
|
||||
{ id: 'options-ui', script: '/js/options-ui-module.js', weight: 13 },
|
||||
{ id: 'socket-client', script: '/js/socket-client-module.js', weight: 17 },
|
||||
|
||||
@@ -13,20 +13,21 @@ class LocalizationModule extends BaseModule {
|
||||
|
||||
// Current locale
|
||||
this.translations = {};
|
||||
this.defaultLocale = 'en-us';
|
||||
this.defaultLocale = 'en_US';
|
||||
this.currentLocale = this.defaultLocale;
|
||||
this.dependencies = ['persistence-manager'];
|
||||
|
||||
// Available translations
|
||||
this.languageNames = {
|
||||
'en-us': 'English (US)',
|
||||
'en-gb': 'English (UK)',
|
||||
'de-de': 'Deutsch (Deutschland)'
|
||||
'en_US': 'English (US)',
|
||||
'de_DE': 'Deutsch (Deutschland)'
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
'setLocale',
|
||||
'applyServerLocale',
|
||||
'normalizeLocale',
|
||||
'getLocale',
|
||||
'translate',
|
||||
'getAvailableLocales',
|
||||
@@ -44,7 +45,7 @@ class LocalizationModule extends BaseModule {
|
||||
this.reportProgress(10, "Initializing localization");
|
||||
|
||||
// Load default English locale
|
||||
await this.loadTranslations('en-us');
|
||||
await this.loadTranslations(this.defaultLocale);
|
||||
this.reportProgress(50, "Loaded default locale");
|
||||
|
||||
// Get stored locale from persistence manager if available
|
||||
@@ -53,27 +54,26 @@ class LocalizationModule extends BaseModule {
|
||||
if (persistenceManager) {
|
||||
const storedLocale = persistenceManager.getPreference('app', 'locale');
|
||||
if (storedLocale) {
|
||||
console.log(`Localization: Found stored locale: ${storedLocale}`);
|
||||
await this.loadTranslations(storedLocale);
|
||||
this.currentLocale = storedLocale;
|
||||
this.reportProgress(80, `Loaded stored locale: ${storedLocale}`);
|
||||
const normalizedLocale = this.normalizeLocale(storedLocale);
|
||||
console.log(`Localization: Found stored locale: ${normalizedLocale}`);
|
||||
await this.loadTranslations(normalizedLocale);
|
||||
this.currentLocale = normalizedLocale;
|
||||
this.reportProgress(80, `Loaded stored locale: ${normalizedLocale}`);
|
||||
} else {
|
||||
// If no stored locale, ensure en-us is the default and persist it
|
||||
console.log('Localization: No stored locale found, using default en-us');
|
||||
persistenceManager.updatePreference('app', 'locale', 'en-us');
|
||||
persistenceManager.updatePreference('tts', 'language', 'en-us');
|
||||
this.currentLocale = 'en-us';
|
||||
this.reportProgress(80, "Using default locale: en-us");
|
||||
console.log(`Localization: No stored locale found, using default ${this.defaultLocale}`);
|
||||
this.currentLocale = this.defaultLocale;
|
||||
this.reportProgress(80, `Using default locale: ${this.defaultLocale}`);
|
||||
}
|
||||
} else {
|
||||
console.log('Localization: Persistence manager not available, using default en-us locale');
|
||||
this.reportProgress(80, "Using default locale: en-us");
|
||||
console.log(`Localization: Persistence manager not available, using default ${this.defaultLocale} locale`);
|
||||
this.reportProgress(80, `Using default locale: ${this.defaultLocale}`);
|
||||
}
|
||||
|
||||
// Dispatch event to notify about loaded locale
|
||||
document.dispatchEvent(new CustomEvent('localization:languageChanged', {
|
||||
detail: { locale: this.currentLocale }
|
||||
}));
|
||||
document.documentElement.lang = this.currentLocale.replace('_', '-');
|
||||
|
||||
this.reportProgress(100, "Localization ready");
|
||||
return true;
|
||||
@@ -90,14 +90,12 @@ class LocalizationModule extends BaseModule {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadTranslations(locale) {
|
||||
if (this.translations[locale]) {
|
||||
const normalizedLocale = this.normalizeLocale(locale);
|
||||
if (this.translations[normalizedLocale]) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
|
||||
// Try to load the exact locale
|
||||
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||
|
||||
@@ -106,7 +104,7 @@ class LocalizationModule extends BaseModule {
|
||||
this.translations[normalizedLocale] = translations;
|
||||
} else {
|
||||
// If exact locale not found, try to load just the language part
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
const langPart = normalizedLocale.split('_')[0];
|
||||
if (langPart !== normalizedLocale) {
|
||||
const langResponse = await fetch(`/locales/${langPart}.json`);
|
||||
if (langResponse.ok) {
|
||||
@@ -128,12 +126,12 @@ class LocalizationModule extends BaseModule {
|
||||
* @param {string} locale - Locale to set
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async setLocale(locale) {
|
||||
async setLocale(locale, options = {}) {
|
||||
if (!locale) return false;
|
||||
|
||||
try {
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
const normalizedLocale = this.normalizeLocale(locale);
|
||||
const userInitiated = options.userInitiated !== false;
|
||||
|
||||
// Load translations if not already loaded
|
||||
if (!this.translations[normalizedLocale]) {
|
||||
@@ -148,12 +146,23 @@ class LocalizationModule extends BaseModule {
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
|
||||
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
|
||||
if (userInitiated) {
|
||||
persistenceManager.updatePreference('app', 'localeUserOverride', true);
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.lang = normalizedLocale.replace('_', '-');
|
||||
|
||||
// Dispatch locale change event
|
||||
this.dispatchEvent('locale-changed', {
|
||||
locale: normalizedLocale
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('locale:changed', {
|
||||
detail: { locale: normalizedLocale }
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent('localization:languageChanged', {
|
||||
detail: { locale: normalizedLocale }
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -161,6 +170,21 @@ class LocalizationModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async applyServerLocale(locale) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const userOverride = persistenceManager?.getPreference('app', 'localeUserOverride', false);
|
||||
if (userOverride) {
|
||||
return false;
|
||||
}
|
||||
return this.setLocale(locale, { userInitiated: false });
|
||||
}
|
||||
|
||||
normalizeLocale(locale) {
|
||||
const normalized = String(locale || this.defaultLocale).trim().replace('-', '_').toLowerCase();
|
||||
if (normalized.startsWith('de')) return 'de_DE';
|
||||
return 'en_US';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale
|
||||
@@ -175,7 +199,7 @@ class LocalizationModule extends BaseModule {
|
||||
* @returns {string} - Language code
|
||||
*/
|
||||
getLanguage() {
|
||||
return this.currentLocale.split('-')[0];
|
||||
return this.currentLocale.split('_')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +221,7 @@ class LocalizationModule extends BaseModule {
|
||||
if (!locale) return '';
|
||||
|
||||
// Normalize locale
|
||||
const normalizedLocale = locale.toLowerCase();
|
||||
const normalizedLocale = this.normalizeLocale(locale);
|
||||
|
||||
// Try exact match
|
||||
if (this.languageNames[normalizedLocale]) {
|
||||
@@ -205,7 +229,7 @@ class LocalizationModule extends BaseModule {
|
||||
}
|
||||
|
||||
// Try language part only
|
||||
const langPart = normalizedLocale.split('-')[0];
|
||||
const langPart = normalizedLocale.split('_')[0];
|
||||
if (this.languageNames[langPart]) {
|
||||
return this.languageNames[langPart];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class MarkupParserModule extends BaseModule {
|
||||
'parse',
|
||||
'parseParagraph',
|
||||
'parseInline',
|
||||
'parseImageOptions',
|
||||
'parseSfxOptions',
|
||||
'parseMusicOptions',
|
||||
'markdownToHtml',
|
||||
'markdownToPlainText',
|
||||
@@ -38,7 +40,6 @@ class MarkupParserModule extends BaseModule {
|
||||
const text = String(input || '').replace(/\r\n?/g, '\n');
|
||||
const blocks = [];
|
||||
let paragraphBuffer = [];
|
||||
let nextParagraphRole = null;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return;
|
||||
@@ -48,8 +49,7 @@ class MarkupParserModule extends BaseModule {
|
||||
const paragraph = this.parseParagraph(raw);
|
||||
if (!paragraph.text) return;
|
||||
|
||||
const role = nextParagraphRole || 'body';
|
||||
nextParagraphRole = null;
|
||||
const role = 'body';
|
||||
blocks.push(this.buildParagraphBlock(paragraph, role));
|
||||
};
|
||||
|
||||
@@ -61,65 +61,6 @@ class MarkupParserModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i);
|
||||
if (chapter) {
|
||||
flushParagraph();
|
||||
const heading = (chapter[1] || chapter[2] || '').trim();
|
||||
if (heading) {
|
||||
const normalizedHeading = this.normalizeParagraph(heading);
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.markdownToPlainText(normalizedHeading),
|
||||
layoutText: this.markdownToHtml(normalizedHeading),
|
||||
role: 'chapter-heading'
|
||||
});
|
||||
}
|
||||
nextParagraphRole = 'chapter-first';
|
||||
return;
|
||||
}
|
||||
|
||||
const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i);
|
||||
if (section) {
|
||||
flushParagraph();
|
||||
const heading = (section[1] || section[2] || '').trim();
|
||||
if (heading) {
|
||||
const normalizedHeading = this.normalizeParagraph(heading);
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
text: this.markdownToPlainText(normalizedHeading),
|
||||
layoutText: this.markdownToHtml(normalizedHeading),
|
||||
role: 'section-heading'
|
||||
});
|
||||
}
|
||||
nextParagraphRole = 'textblock-first';
|
||||
return;
|
||||
}
|
||||
|
||||
const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i);
|
||||
if (image) {
|
||||
flushParagraph();
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
size: image[1].toLowerCase(),
|
||||
filename: image[2].trim(),
|
||||
url: this.resolveAssetUrl('images', image[2].trim())
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i);
|
||||
if (music) {
|
||||
flushParagraph();
|
||||
const options = this.parseMusicOptions(music[1] || 'crossfade');
|
||||
blocks.push({
|
||||
type: 'music',
|
||||
...options,
|
||||
filename: music[2].trim(),
|
||||
url: this.resolveAssetUrl('music', music[2].trim())
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
paragraphBuffer.push(line);
|
||||
});
|
||||
|
||||
@@ -128,6 +69,35 @@ class MarkupParserModule extends BaseModule {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
parseImageOptions(optionText) {
|
||||
const options = {
|
||||
size: 'landscape',
|
||||
leadInSeconds: 0
|
||||
};
|
||||
|
||||
String(optionText || 'landscape')
|
||||
.split(/[,\s]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((token, index) => {
|
||||
const lower = token.toLowerCase();
|
||||
const [key, value] = lower.split('=');
|
||||
|
||||
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
|
||||
options.size = lower === 'widescreen' ? 'landscape' : lower;
|
||||
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
|
||||
const seconds = Number(value);
|
||||
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
|
||||
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
|
||||
options.leadInSeconds = Number(lower.replace(/s$/, ''));
|
||||
} else if (index === 0) {
|
||||
console.warn(`MarkupParser: Unknown image size "${token}", using landscape`);
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
parseMusicOptions(optionText) {
|
||||
const options = {
|
||||
mode: 'crossfade',
|
||||
@@ -162,6 +132,45 @@ class MarkupParserModule extends BaseModule {
|
||||
return options;
|
||||
}
|
||||
|
||||
parseSfxOptions(optionText) {
|
||||
const options = {
|
||||
maxDurationSeconds: 0,
|
||||
endMode: 'stop',
|
||||
fadeDurationSeconds: 2
|
||||
};
|
||||
|
||||
String(optionText || '')
|
||||
.split(/[,\s]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(token => {
|
||||
const lower = token.toLowerCase();
|
||||
const [key, value] = lower.split('=');
|
||||
|
||||
if (['fade', 'fadeout', 'fade-out'].includes(lower)) {
|
||||
options.endMode = 'fade';
|
||||
} else if (['stop', 'cut', 'halt'].includes(lower)) {
|
||||
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(lower)) {
|
||||
options.maxDurationSeconds = Number(lower.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;
|
||||
}
|
||||
|
||||
parseParagraph(rawText) {
|
||||
const inline = this.parseInline(this.normalizeParagraph(rawText));
|
||||
return {
|
||||
@@ -185,35 +194,9 @@ class MarkupParserModule extends BaseModule {
|
||||
}
|
||||
|
||||
parseInline(text) {
|
||||
const cueMarkers = [];
|
||||
let output = '';
|
||||
let cursor = 0;
|
||||
const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi;
|
||||
|
||||
for (const match of text.matchAll(markerPattern)) {
|
||||
output += text.slice(cursor, match.index);
|
||||
const charIndex = output.length;
|
||||
const wordIndex = this.countWords(output);
|
||||
const type = match[1].toLowerCase();
|
||||
const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null;
|
||||
|
||||
cueMarkers.push({
|
||||
type,
|
||||
mode,
|
||||
filename: match[3].trim(),
|
||||
url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()),
|
||||
charIndex,
|
||||
wordIndex
|
||||
});
|
||||
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
|
||||
output += text.slice(cursor);
|
||||
|
||||
return {
|
||||
text: output.replace(/\s{2,}/g, ' ').trim(),
|
||||
cueMarkers
|
||||
text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
|
||||
cueMarkers: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ModuleRegistry {
|
||||
if (dependencies) {
|
||||
this.moduleDependencies.set(module.id, dependencies);
|
||||
|
||||
// Also set them on the module itself for backwards compatibility
|
||||
// Mirror explicit dependencies onto the module instance.
|
||||
if (module.dependencies === undefined) {
|
||||
module.dependencies = [...dependencies];
|
||||
}
|
||||
|
||||
@@ -46,9 +46,30 @@ class OptionsUIModule extends BaseModule {
|
||||
'dispatchApiChangeEvent',
|
||||
'getPreference',
|
||||
'updatePreference',
|
||||
'updateUIText',
|
||||
'renderProviderStatuses'
|
||||
]);
|
||||
}
|
||||
|
||||
t(key, params = {}) {
|
||||
const localization = this.getModule('localization');
|
||||
return localization?.translate?.(key, params) || key;
|
||||
}
|
||||
|
||||
updateUIText() {
|
||||
if (!this.modal) return;
|
||||
const wasOpen = this.modal.style.display === 'flex';
|
||||
this.modal.remove();
|
||||
this.modal = null;
|
||||
this.elements = {};
|
||||
this.createModal();
|
||||
this.setupPreferenceBindings();
|
||||
this.populateTtsSystems();
|
||||
this.populateLanguages();
|
||||
this.populateVoices();
|
||||
this.renderProviderStatuses();
|
||||
if (wasOpen) this.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an API change event
|
||||
@@ -135,7 +156,7 @@ class OptionsUIModule extends BaseModule {
|
||||
header.className = 'modal-header';
|
||||
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'Options';
|
||||
title.textContent = this.t('options.title');
|
||||
header.appendChild(title);
|
||||
|
||||
const closeButton = document.createElement('span');
|
||||
@@ -156,7 +177,7 @@ class OptionsUIModule extends BaseModule {
|
||||
appSettingsSection.className = 'options-section';
|
||||
|
||||
const appSettingsTitle = document.createElement('h3');
|
||||
appSettingsTitle.textContent = 'Application Settings';
|
||||
appSettingsTitle.textContent = this.t('options.applicationSettings');
|
||||
appSettingsSection.appendChild(appSettingsTitle);
|
||||
|
||||
// Language
|
||||
@@ -164,7 +185,7 @@ class OptionsUIModule extends BaseModule {
|
||||
languageContainer.className = 'option-item';
|
||||
|
||||
const languageLabel = document.createElement('label');
|
||||
languageLabel.textContent = 'Language:';
|
||||
languageLabel.textContent = this.t('options.language') + ':';
|
||||
languageContainer.appendChild(languageLabel);
|
||||
|
||||
this.elements.language = createUIElement('select', {
|
||||
@@ -178,7 +199,7 @@ class OptionsUIModule extends BaseModule {
|
||||
speedContainer.className = 'option-item';
|
||||
|
||||
const speedLabel = document.createElement('label');
|
||||
speedLabel.textContent = 'Speed:';
|
||||
speedLabel.textContent = this.t('options.speed') + ':';
|
||||
speedContainer.appendChild(speedLabel);
|
||||
|
||||
const speedValue = document.createElement('span');
|
||||
@@ -210,7 +231,7 @@ class OptionsUIModule extends BaseModule {
|
||||
ttsSection.className = 'options-section';
|
||||
|
||||
const ttsTitle = document.createElement('h3');
|
||||
ttsTitle.textContent = 'Text-to-Speech';
|
||||
ttsTitle.textContent = this.t('options.speech');
|
||||
ttsSection.appendChild(ttsTitle);
|
||||
|
||||
// TTS Enable
|
||||
@@ -218,7 +239,7 @@ class OptionsUIModule extends BaseModule {
|
||||
ttsEnableContainer.className = 'option-item';
|
||||
|
||||
const ttsEnableLabel = document.createElement('label');
|
||||
ttsEnableLabel.textContent = 'Enable TTS:';
|
||||
ttsEnableLabel.textContent = this.t('options.enableSpeech') + ':';
|
||||
ttsEnableContainer.appendChild(ttsEnableLabel);
|
||||
|
||||
this.elements.ttsEnabled = createUIElement('input', {
|
||||
@@ -233,7 +254,7 @@ class OptionsUIModule extends BaseModule {
|
||||
ttsSystemContainer.className = 'option-item';
|
||||
|
||||
const ttsSystemLabel = document.createElement('label');
|
||||
ttsSystemLabel.textContent = 'TTS System:';
|
||||
ttsSystemLabel.textContent = this.t('options.provider') + ':';
|
||||
ttsSystemContainer.appendChild(ttsSystemLabel);
|
||||
|
||||
this.elements.ttsSystem = createUIElement('select', {
|
||||
@@ -252,7 +273,7 @@ class OptionsUIModule extends BaseModule {
|
||||
ttsVoiceContainer.className = 'option-item';
|
||||
|
||||
const ttsVoiceLabel = document.createElement('label');
|
||||
ttsVoiceLabel.textContent = 'Voice:';
|
||||
ttsVoiceLabel.textContent = this.t('options.voice') + ':';
|
||||
ttsVoiceContainer.appendChild(ttsVoiceLabel);
|
||||
|
||||
this.elements.ttsVoice = createUIElement('select', {
|
||||
@@ -272,7 +293,7 @@ class OptionsUIModule extends BaseModule {
|
||||
audioSection.className = 'options-section';
|
||||
|
||||
const audioTitle = document.createElement('h3');
|
||||
audioTitle.textContent = 'Audio';
|
||||
audioTitle.textContent = this.t('options.audio');
|
||||
audioSection.appendChild(audioTitle);
|
||||
|
||||
// Master Volume
|
||||
@@ -280,7 +301,7 @@ class OptionsUIModule extends BaseModule {
|
||||
masterVolumeContainer.className = 'option-item';
|
||||
|
||||
const masterVolumeLabel = document.createElement('label');
|
||||
masterVolumeLabel.textContent = 'Master Volume:';
|
||||
masterVolumeLabel.textContent = this.t('options.masterVolume') + ':';
|
||||
masterVolumeContainer.appendChild(masterVolumeLabel);
|
||||
|
||||
const masterVolumeValue = document.createElement('span');
|
||||
@@ -310,7 +331,7 @@ class OptionsUIModule extends BaseModule {
|
||||
ttsVolumeContainer.className = 'option-item';
|
||||
|
||||
const ttsVolumeLabel = document.createElement('label');
|
||||
ttsVolumeLabel.textContent = 'Speech Volume:';
|
||||
ttsVolumeLabel.textContent = this.t('options.speechVolume') + ':';
|
||||
ttsVolumeContainer.appendChild(ttsVolumeLabel);
|
||||
|
||||
const ttsVolumeValue = document.createElement('span');
|
||||
@@ -340,7 +361,7 @@ class OptionsUIModule extends BaseModule {
|
||||
musicVolumeContainer.className = 'option-item';
|
||||
|
||||
const musicVolumeLabel = document.createElement('label');
|
||||
musicVolumeLabel.textContent = 'Music Volume:';
|
||||
musicVolumeLabel.textContent = this.t('options.musicVolume') + ':';
|
||||
musicVolumeContainer.appendChild(musicVolumeLabel);
|
||||
|
||||
const musicVolumeValue = document.createElement('span');
|
||||
@@ -370,7 +391,7 @@ class OptionsUIModule extends BaseModule {
|
||||
sfxVolumeContainer.className = 'option-item';
|
||||
|
||||
const sfxVolumeLabel = document.createElement('label');
|
||||
sfxVolumeLabel.textContent = 'Sound Effects Volume:';
|
||||
sfxVolumeLabel.textContent = this.t('options.sfxVolume') + ':';
|
||||
sfxVolumeContainer.appendChild(sfxVolumeLabel);
|
||||
|
||||
const sfxVolumeValue = document.createElement('span');
|
||||
@@ -404,7 +425,7 @@ class OptionsUIModule extends BaseModule {
|
||||
footer.className = 'modal-footer';
|
||||
|
||||
const closeModalButton = document.createElement('button');
|
||||
closeModalButton.textContent = 'Close';
|
||||
closeModalButton.textContent = this.t('options.close');
|
||||
closeModalButton.onclick = () => this.hide();
|
||||
footer.appendChild(closeModalButton);
|
||||
|
||||
@@ -432,7 +453,7 @@ class OptionsUIModule extends BaseModule {
|
||||
elevenLabsSettings.style.display = 'none';
|
||||
|
||||
const elevenLabsTitle = document.createElement('h3');
|
||||
elevenLabsTitle.textContent = 'ElevenLabs API Settings';
|
||||
elevenLabsTitle.textContent = this.t('options.elevenLabsSettings');
|
||||
elevenLabsSettings.appendChild(elevenLabsTitle);
|
||||
|
||||
// ElevenLabs API Key
|
||||
@@ -440,7 +461,7 @@ class OptionsUIModule extends BaseModule {
|
||||
elevenLabsApiKeyContainer.className = 'option-item';
|
||||
|
||||
const elevenLabsApiKeyLabel = document.createElement('label');
|
||||
elevenLabsApiKeyLabel.textContent = 'API Key:';
|
||||
elevenLabsApiKeyLabel.textContent = this.t('options.apiKey') + ':';
|
||||
elevenLabsApiKeyContainer.appendChild(elevenLabsApiKeyLabel);
|
||||
|
||||
this.elements.elevenLabsApiKey = createUIElement('input', {
|
||||
@@ -455,7 +476,7 @@ class OptionsUIModule extends BaseModule {
|
||||
elevenLabsApiUrlContainer.className = 'option-item';
|
||||
|
||||
const elevenLabsApiUrlLabel = document.createElement('label');
|
||||
elevenLabsApiUrlLabel.textContent = 'API URL:';
|
||||
elevenLabsApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
|
||||
elevenLabsApiUrlContainer.appendChild(elevenLabsApiUrlLabel);
|
||||
|
||||
this.elements.elevenLabsApiUrl = createUIElement('input', {
|
||||
@@ -471,7 +492,7 @@ class OptionsUIModule extends BaseModule {
|
||||
openaiSettings.style.display = 'none';
|
||||
|
||||
const openaiTitle = document.createElement('h3');
|
||||
openaiTitle.textContent = 'OpenAI API Settings';
|
||||
openaiTitle.textContent = this.t('options.openAiSettings');
|
||||
openaiSettings.appendChild(openaiTitle);
|
||||
|
||||
// OpenAI API Key
|
||||
@@ -479,7 +500,7 @@ class OptionsUIModule extends BaseModule {
|
||||
openaiApiKeyContainer.className = 'option-item';
|
||||
|
||||
const openaiApiKeyLabel = document.createElement('label');
|
||||
openaiApiKeyLabel.textContent = 'API Key:';
|
||||
openaiApiKeyLabel.textContent = this.t('options.apiKey') + ':';
|
||||
openaiApiKeyContainer.appendChild(openaiApiKeyLabel);
|
||||
|
||||
this.elements.openaiApiKey = createUIElement('input', {
|
||||
@@ -494,7 +515,7 @@ class OptionsUIModule extends BaseModule {
|
||||
openaiApiUrlContainer.className = 'option-item';
|
||||
|
||||
const openaiApiUrlLabel = document.createElement('label');
|
||||
openaiApiUrlLabel.textContent = 'API URL:';
|
||||
openaiApiUrlLabel.textContent = this.t('options.apiUrl') + ':';
|
||||
openaiApiUrlContainer.appendChild(openaiApiUrlLabel);
|
||||
|
||||
this.elements.openaiApiUrl = createUIElement('input', {
|
||||
|
||||
@@ -33,7 +33,7 @@ class PersistenceManagerModule extends BaseModule {
|
||||
enabled: false,
|
||||
preferred_handler: 'none',
|
||||
speed: 1.0,
|
||||
language: 'en-us',
|
||||
language: 'en_US',
|
||||
voice: '',
|
||||
'elevenlabs-tts_api_key': '',
|
||||
'elevenlabs-tts_api_url': 'https://api.elevenlabs.io/v1',
|
||||
@@ -47,8 +47,10 @@ class PersistenceManagerModule extends BaseModule {
|
||||
sfxVolume: 1.0,
|
||||
},
|
||||
app: {
|
||||
locale: 'en-us',
|
||||
locale: null,
|
||||
localeUserOverride: false,
|
||||
speed: 1.0,
|
||||
autoplay: true,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -649,7 +651,6 @@ class PersistenceManagerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to parse as JSON for backward compatibility
|
||||
const customTransformer = JSON.parse(element.dataset.prefTransform);
|
||||
if (customTransformer && typeof customTransformer === 'object') {
|
||||
transformer = customTransformer;
|
||||
|
||||
@@ -9,7 +9,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
super('sentence-queue', 'Sentence Queue');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager'];
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager'];
|
||||
|
||||
// Queue state
|
||||
this.sentenceQueue = [];
|
||||
@@ -18,6 +18,9 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
// Cache for prefetched sentences
|
||||
this.preparedCache = new Map();
|
||||
this.prefetchingCache = new Map();
|
||||
this.activeImageWrap = null;
|
||||
this.autoplay = true;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
@@ -26,8 +29,18 @@ class SentenceQueueModule extends BaseModule {
|
||||
'processNextSentence',
|
||||
'setOnSentenceReady',
|
||||
'completeSentence',
|
||||
'getCacheKey',
|
||||
'getPreparedSentence',
|
||||
'prefetchAhead',
|
||||
'isSpeechItem',
|
||||
'getMediaPauseSeconds',
|
||||
'readFirstFiniteNumber',
|
||||
'waitForSkippableMediaPause',
|
||||
'shouldAutoplay',
|
||||
'waitForManualContinue',
|
||||
'prepareSentence',
|
||||
'prepareLayout',
|
||||
'prepareImageLayout',
|
||||
'extractWords',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
@@ -56,6 +69,16 @@ class SentenceQueueModule extends BaseModule {
|
||||
});
|
||||
|
||||
this.reportProgress(100, "Sentence queue ready");
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||
this.autoplay = persistenceManager.getPreference('app', 'autoplay', true) !== false;
|
||||
}
|
||||
this.addEventListener(document, 'preference-updated', (event) => {
|
||||
const { category, key, value } = event.detail || {};
|
||||
if (category === 'app' && key === 'autoplay') {
|
||||
this.autoplay = value !== false;
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing Sentence Queue:", error);
|
||||
@@ -106,44 +129,11 @@ class SentenceQueueModule extends BaseModule {
|
||||
const item = this.sentenceQueue[0];
|
||||
|
||||
try {
|
||||
// Check if sentence is already in cache
|
||||
const cacheKey = `${item.id || ''}:${item.text}`;
|
||||
let sentence = this.preparedCache.get(cacheKey);
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
|
||||
if (!sentence) {
|
||||
// Prepare complete sentence object (TTS + layout in parallel)
|
||||
sentence = await this.prepareSentence(item);
|
||||
} else {
|
||||
console.log('SentenceQueue: Using cached sentence');
|
||||
this.preparedCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// Prefetch next sentence while current displays
|
||||
if (this.sentenceQueue.length > 1) {
|
||||
const nextItem = this.sentenceQueue[1];
|
||||
const nextCacheKey = `${nextItem.id || ''}:${nextItem.text}`;
|
||||
if (!this.preparedCache.has(nextCacheKey)) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-generating', reason: 'prefetch-start', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-generating', { reason: 'prefetch-start', sentenceId: nextItem.id });
|
||||
this.prepareSentence(nextItem)
|
||||
.then(prepared => {
|
||||
this.preparedCache.set(nextCacheKey, prepared);
|
||||
console.log('SentenceQueue: Prefetched next sentence');
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id });
|
||||
})
|
||||
.catch(err => console.warn('SentenceQueue: Prefetch failed:', err));
|
||||
}
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: item.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: item.id });
|
||||
}
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
// generation for the next spoken paragraph.
|
||||
this.prefetchAhead();
|
||||
|
||||
// Notify display handler with complete sentence
|
||||
if (this.onSentenceReadyCallback) {
|
||||
@@ -153,6 +143,15 @@ class SentenceQueueModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
const mediaPauseSeconds = this.getMediaPauseSeconds(sentence);
|
||||
if (mediaPauseSeconds > 0) {
|
||||
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
|
||||
}
|
||||
|
||||
if (sentence.kind === 'paragraph' && !this.shouldAutoplay()) {
|
||||
await this.waitForManualContinue(sentence.id);
|
||||
}
|
||||
|
||||
// Remove from queue and continue
|
||||
this.sentenceQueue.shift();
|
||||
if (item.callback) item.callback({ success: true });
|
||||
@@ -275,12 +274,17 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
const imageLayout = metadata.type === 'image'
|
||||
? await this.prepareImageLayout(metadata)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: metadata.type,
|
||||
text: text || '',
|
||||
turnId: metadata.turnId ?? null,
|
||||
status: 'ready',
|
||||
metadata,
|
||||
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
|
||||
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
|
||||
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
|
||||
element: null,
|
||||
@@ -309,6 +313,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
id,
|
||||
kind: metadata.type === 'heading' ? 'heading' : 'paragraph',
|
||||
text,
|
||||
turnId: metadata.turnId ?? null,
|
||||
paragraphIndex: metadata.paragraphIndex ?? null,
|
||||
isFirstParagraphInChapter: Boolean(metadata.isFirstParagraphInChapter),
|
||||
role: metadata.role || (metadata.type === 'heading' ? 'chapter-heading' : 'body'),
|
||||
@@ -383,10 +388,27 @@ class SentenceQueueModule extends BaseModule {
|
||||
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
const wrap = this.consumeImageWrap();
|
||||
|
||||
// Measures are consumed in line order by the line breaker.
|
||||
const wrappedWidth = wrap ? Math.max(120, containerWidth - wrap.width) : containerWidth;
|
||||
const imageLeftOffset = wrap && wrap.side !== 'right' ? wrap.width : 0;
|
||||
const imageRightOffset = wrap && wrap.side === 'right' ? wrap.width : 0;
|
||||
const measures = isHeading
|
||||
? [containerWidth]
|
||||
: wrap && metadata.dropCap
|
||||
? [
|
||||
Math.max(120, wrappedWidth - dropCapWidth),
|
||||
Math.max(120, wrappedWidth - dropCapWidth),
|
||||
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(wrappedWidth),
|
||||
containerWidth
|
||||
]
|
||||
: wrap
|
||||
? [
|
||||
Math.max(120, wrappedWidth - indentWidth),
|
||||
...Array(Math.max(0, wrap.lines - 1)).fill(wrappedWidth),
|
||||
containerWidth
|
||||
]
|
||||
: metadata.dropCap
|
||||
? [
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
@@ -398,8 +420,34 @@ class SentenceQueueModule extends BaseModule {
|
||||
containerWidth,
|
||||
containerWidth
|
||||
];
|
||||
const lineOffsets = isHeading
|
||||
? [0]
|
||||
: wrap && metadata.dropCap
|
||||
? [
|
||||
imageLeftOffset + dropCapWidth,
|
||||
imageLeftOffset + dropCapWidth,
|
||||
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(imageLeftOffset),
|
||||
0
|
||||
]
|
||||
: wrap
|
||||
? [
|
||||
imageLeftOffset + indentWidth,
|
||||
...Array(Math.max(0, wrap.lines - 1)).fill(imageLeftOffset),
|
||||
0
|
||||
]
|
||||
: metadata.dropCap
|
||||
? [
|
||||
dropCapWidth,
|
||||
dropCapWidth,
|
||||
0
|
||||
]
|
||||
: [
|
||||
indentWidth,
|
||||
0,
|
||||
0
|
||||
];
|
||||
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, imageRightOffset: ${imageRightOffset.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
|
||||
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
|
||||
measures,
|
||||
@@ -413,13 +461,23 @@ class SentenceQueueModule extends BaseModule {
|
||||
throw new Error('Paragraph layout calculation failed');
|
||||
}
|
||||
|
||||
if (wrap) {
|
||||
const usedLines = Math.max(0, (layout.breaks?.length || 1) - 1);
|
||||
const remainingLines = Math.max(0, wrap.lines - usedLines);
|
||||
this.activeImageWrap = remainingLines > 0
|
||||
? { ...wrap, lines: remainingLines }
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
breaks: layout.breaks,
|
||||
nodes: layout.nodes,
|
||||
processedText: layout.processedText || text,
|
||||
sourceLayoutText: layoutText,
|
||||
measures,
|
||||
lineOffsets,
|
||||
indentWidth,
|
||||
imageWrap: wrap,
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapLines,
|
||||
@@ -437,6 +495,282 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
shouldAutoplay() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||
return persistenceManager.getPreference('app', 'autoplay', this.autoplay) !== false;
|
||||
}
|
||||
return this.autoplay !== false;
|
||||
}
|
||||
|
||||
waitForManualContinue(sentenceId) {
|
||||
document.documentElement.dataset.skippablePause = 'true';
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'paused', reason: 'autoplay-disabled', sentenceId }
|
||||
}));
|
||||
return new Promise(resolve => {
|
||||
let resolved = false;
|
||||
const finish = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
delete document.documentElement.dataset.skippablePause;
|
||||
document.removeEventListener('ui:command', onCommand);
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'manual-continue', sentenceId }
|
||||
}));
|
||||
resolve();
|
||||
};
|
||||
const onCommand = (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
document.addEventListener('ui:command', onCommand);
|
||||
});
|
||||
}
|
||||
|
||||
getCacheKey(item) {
|
||||
return `${item?.id || ''}:${item?.text || ''}`;
|
||||
}
|
||||
|
||||
async getPreparedSentence(item) {
|
||||
const cacheKey = this.getCacheKey(item);
|
||||
const cached = this.preparedCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
console.log('SentenceQueue: Using cached sentence');
|
||||
this.preparedCache.delete(cacheKey);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = this.prefetchingCache.get(cacheKey);
|
||||
if (pending) {
|
||||
console.log('SentenceQueue: Awaiting active prefetch');
|
||||
try {
|
||||
const prepared = await pending;
|
||||
return prepared || await this.prepareSentence(item);
|
||||
} finally {
|
||||
this.prefetchingCache.delete(cacheKey);
|
||||
this.preparedCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
return this.prepareSentence(item);
|
||||
}
|
||||
|
||||
prefetchAhead(maxLookahead = 4) {
|
||||
if (this.sentenceQueue.length <= 1) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id });
|
||||
return;
|
||||
}
|
||||
|
||||
let started = 0;
|
||||
let spokenPrepared = 0;
|
||||
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
|
||||
|
||||
for (let index = 1; index < limit; index += 1) {
|
||||
const nextItem = this.sentenceQueue[index];
|
||||
const nextCacheKey = this.getCacheKey(nextItem);
|
||||
if (this.preparedCache.has(nextCacheKey) || this.prefetchingCache.has(nextCacheKey)) {
|
||||
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = this.isSpeechItem(nextItem) ? 'playing-generating' : 'playing-ready';
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state, reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index }
|
||||
}));
|
||||
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
|
||||
|
||||
const promise = this.prepareSentence(nextItem)
|
||||
.then(prepared => {
|
||||
this.preparedCache.set(nextCacheKey, prepared);
|
||||
console.log('SentenceQueue: Prefetched queued item', { sentenceId: nextItem.id, queueIndex: index });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
|
||||
return prepared;
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('SentenceQueue: Prefetch failed:', err);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.prefetchingCache.delete(nextCacheKey);
|
||||
});
|
||||
|
||||
this.prefetchingCache.set(nextCacheKey, promise);
|
||||
started += 1;
|
||||
|
||||
if (this.isSpeechItem(nextItem)) {
|
||||
spokenPrepared += 1;
|
||||
}
|
||||
|
||||
if (spokenPrepared >= 1 && started >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (started === 0) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-already-ready', sentenceId: this.sentenceQueue[0]?.id });
|
||||
}
|
||||
}
|
||||
|
||||
isSpeechItem(item) {
|
||||
const type = item?.type || 'paragraph';
|
||||
return type === 'paragraph' || type === 'heading' || !['image', 'music'].includes(type);
|
||||
}
|
||||
|
||||
getMediaPauseSeconds(sentence) {
|
||||
if (!sentence || !['image', 'music'].includes(sentence.kind)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const metadata = sentence.metadata || {};
|
||||
const configuredPause = this.readFirstFiniteNumber(
|
||||
metadata.leadInSeconds,
|
||||
metadata.leadIn,
|
||||
metadata.pause,
|
||||
metadata.delay,
|
||||
0
|
||||
);
|
||||
|
||||
if (sentence.kind !== 'image') {
|
||||
return configuredPause;
|
||||
}
|
||||
|
||||
const revealSeconds = Number(metadata.imageRevealSeconds || metadata.revealSeconds || 0.9);
|
||||
return Math.max(configuredPause, Number.isFinite(revealSeconds) ? revealSeconds : 0.9);
|
||||
}
|
||||
|
||||
readFirstFiniteNumber(...values) {
|
||||
for (const value of values) {
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number)) {
|
||||
return Math.max(0, number);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
waitForSkippableMediaPause(seconds, kind = 'media', sentenceId = null) {
|
||||
const duration = Math.max(0, Number(seconds) || 0) * 1000;
|
||||
if (duration <= 0) return Promise.resolve(false);
|
||||
|
||||
const startedAt = performance.now();
|
||||
console.log(`SentenceQueue: Waiting ${seconds}s for ${kind} lead`, { sentenceId });
|
||||
document.documentElement.dataset.skippablePause = 'true';
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration, sentenceId }
|
||||
}));
|
||||
|
||||
return new Promise(resolve => {
|
||||
let finished = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const finish = (skipped, source = null) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('ui:command', onCommand);
|
||||
delete document.documentElement.dataset.skippablePause;
|
||||
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||
console.log(`SentenceQueue: ${kind} lead ${skipped ? 'skipped' : 'complete'}`, { sentenceId, elapsedMs, source });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}`, duration, elapsedMs, sentenceId }
|
||||
}));
|
||||
resolve(skipped);
|
||||
};
|
||||
|
||||
const onCommand = (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
finish(true, event.detail);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('ui:command', onCommand);
|
||||
timeoutId = setTimeout(() => finish(false), duration);
|
||||
});
|
||||
}
|
||||
|
||||
async prepareImageLayout(metadata = {}) {
|
||||
const storyElement = document.getElementById('story');
|
||||
if (!storyElement) {
|
||||
throw new Error("Story container not found");
|
||||
}
|
||||
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
const probe = document.createElement('p');
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.left = '-8000px';
|
||||
probe.style.top = '-8000px';
|
||||
storyElement.appendChild(probe);
|
||||
const computedStyle = window.getComputedStyle(probe);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||
probe.remove();
|
||||
|
||||
const pageWidth = storyElement.clientWidth;
|
||||
const size = String(metadata.size || 'landscape').toLowerCase();
|
||||
const aspect = size === 'portrait' ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
|
||||
const imageGap = lineHeight * 0.9;
|
||||
const maxWidth = size === 'portrait' ? pageWidth * 0.5 : pageWidth;
|
||||
const naturalHeight = maxWidth / aspect;
|
||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||
const height = imageLineCount * lineHeight;
|
||||
const width = Math.min(maxWidth, height * aspect);
|
||||
const verticalMargin = lineHeight / 2;
|
||||
const lineCount = imageLineCount + 1;
|
||||
|
||||
if (size === 'portrait') {
|
||||
this.activeImageWrap = {
|
||||
lines: lineCount,
|
||||
width: width + imageGap,
|
||||
imageWidth: width,
|
||||
gap: imageGap,
|
||||
height,
|
||||
lineHeight,
|
||||
side: metadata.floatSide || 'left'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
size,
|
||||
aspect,
|
||||
width,
|
||||
height,
|
||||
gap: imageGap,
|
||||
lineCount,
|
||||
imageLineCount,
|
||||
lineHeight,
|
||||
verticalMargin,
|
||||
floatSide: metadata.floatSide || 'left',
|
||||
pageWidth
|
||||
};
|
||||
}
|
||||
|
||||
consumeImageWrap() {
|
||||
if (!this.activeImageWrap || this.activeImageWrap.lines <= 0) {
|
||||
this.activeImageWrap = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrap = { ...this.activeImageWrap };
|
||||
this.activeImageWrap = null;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract words from layout nodes
|
||||
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
|
||||
@@ -546,6 +880,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.preparedCache.clear();
|
||||
this.activeImageWrap = null;
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-cleared' }
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ class SocketClientModule extends BaseModule {
|
||||
super('socket-client', 'Socket Client');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer'];
|
||||
this.dependencies = ['text-buffer', 'markup-parser'];
|
||||
|
||||
this.socket = null;
|
||||
this.textBuffer = null;
|
||||
@@ -31,6 +31,7 @@ class SocketClientModule extends BaseModule {
|
||||
'newGame',
|
||||
'loadGame',
|
||||
'saveGame',
|
||||
'chooseChoice',
|
||||
'hasSaveGame',
|
||||
'getSaveGames',
|
||||
'isGameRunning',
|
||||
@@ -41,7 +42,21 @@ class SocketClientModule extends BaseModule {
|
||||
'off',
|
||||
'emitEvent',
|
||||
'setupGameEventHandlers',
|
||||
'processTextFragment',
|
||||
'processTurnResult',
|
||||
'processParagraphResult',
|
||||
'dispatchTurnTags',
|
||||
'isTimedCueTag',
|
||||
'cueMarkersFromTags',
|
||||
'dispatchChoices',
|
||||
'dispatchInputMode',
|
||||
'isStructuralTag',
|
||||
'blocksFromTags',
|
||||
'enqueueStructuredBlock',
|
||||
'parseImageTagOptions',
|
||||
'parseSfxTagOptions',
|
||||
'parseMusicTagOptions',
|
||||
'resolveAssetUrl',
|
||||
'looksLikeAssetPath',
|
||||
'attemptReconnect',
|
||||
'getConnectionStatus',
|
||||
'loadSocketIO'
|
||||
@@ -166,48 +181,285 @@ class SocketClientModule extends BaseModule {
|
||||
|
||||
// Special handling for narrative text
|
||||
this.socket.on('narrativeResponse', (data) => {
|
||||
if (data && data.text && this.textBuffer) {
|
||||
this.processTextFragment(data.text);
|
||||
}
|
||||
this.processTurnResult(data);
|
||||
});
|
||||
|
||||
// Special handling for introduction text
|
||||
this.socket.on('gameIntroduction', (data) => {
|
||||
if (data && data.introduction && this.textBuffer) {
|
||||
this.processTextFragment(data.introduction);
|
||||
}
|
||||
|
||||
if (data && data.initialRoomDescription && this.textBuffer) {
|
||||
this.processTextFragment(data.initialRoomDescription);
|
||||
}
|
||||
this.socket.on('gameConfig', (data) => {
|
||||
document.dispatchEvent(new CustomEvent('game:config', {
|
||||
detail: data
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a text fragment by adding it to the TextBuffer
|
||||
* @param {string} text - Text fragment to process
|
||||
*/
|
||||
processTextFragment(text) {
|
||||
if (!text) return;
|
||||
|
||||
// Add text to the buffer if available
|
||||
if (this.textBuffer) {
|
||||
console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
|
||||
|
||||
processTurnResult(data) {
|
||||
if (!data) return;
|
||||
|
||||
const turnId = Number(data.turnId);
|
||||
if (!Number.isInteger(turnId) || turnId < 1 || !Array.isArray(data.paragraphs)) {
|
||||
console.error('Socket Client: Invalid TurnResult received', data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
|
||||
document.dispatchEvent(new CustomEvent('story:global-tags', {
|
||||
detail: data.globalTags
|
||||
}));
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('story:turn-start', {
|
||||
detail: { turnId, turn: data }
|
||||
}));
|
||||
|
||||
let pendingParagraph = {
|
||||
role: null,
|
||||
cueTags: []
|
||||
};
|
||||
data.paragraphs.forEach((paragraph) => {
|
||||
pendingParagraph = this.processParagraphResult(paragraph, turnId, pendingParagraph);
|
||||
});
|
||||
|
||||
this.dispatchChoices(Array.isArray(data.choices) ? data.choices : []);
|
||||
this.dispatchInputMode(data.inputMode || (Array.isArray(data.choices) && data.choices.length > 0 ? 'choice' : 'text'));
|
||||
}
|
||||
|
||||
dispatchTurnTags(tags, paragraph = null) {
|
||||
if (!Array.isArray(tags)) return;
|
||||
tags.forEach((tag) => {
|
||||
if (!tag || !tag.key) return;
|
||||
document.dispatchEvent(new CustomEvent('story:tag', {
|
||||
detail: {
|
||||
...tag,
|
||||
paragraph
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
dispatchChoices(choices) {
|
||||
document.dispatchEvent(new CustomEvent('story:choices', {
|
||||
detail: choices
|
||||
}));
|
||||
}
|
||||
|
||||
dispatchInputMode(inputMode) {
|
||||
const mode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text';
|
||||
document.dispatchEvent(new CustomEvent('story:input-mode', {
|
||||
detail: mode
|
||||
}));
|
||||
}
|
||||
|
||||
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
|
||||
const pending = pendingParagraph && typeof pendingParagraph === 'object'
|
||||
? pendingParagraph
|
||||
: { role: pendingParagraph || null, cueTags: [] };
|
||||
const tags = Array.isArray(paragraph?.tags) ? paragraph.tags : [];
|
||||
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
|
||||
const text = String(paragraph?.text || '').trim();
|
||||
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
|
||||
const immediateTags = tags.filter(tag => !this.isStructuralTag(tag) && !this.isTimedCueTag(tag));
|
||||
|
||||
this.dispatchTurnTags(immediateTags, paragraph);
|
||||
blocks.forEach(block => this.enqueueStructuredBlock(block));
|
||||
|
||||
if (!text) {
|
||||
return {
|
||||
role: paragraphRole || pending.role || null,
|
||||
cueTags: [
|
||||
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
||||
...cueTags
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const role = pending.role || paragraphRole || 'body';
|
||||
const cueMarkers = [
|
||||
...(Array.isArray(paragraph.cueMarkers) ? paragraph.cueMarkers : []),
|
||||
...this.cueMarkersFromTags([
|
||||
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
|
||||
...cueTags
|
||||
])
|
||||
];
|
||||
this.enqueueStructuredBlock({
|
||||
type: 'paragraph',
|
||||
text,
|
||||
layoutText: paragraph.layoutText || text,
|
||||
cueMarkers,
|
||||
role,
|
||||
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
|
||||
dropCap: role === 'chapter-first',
|
||||
addTopSpace: role === 'textblock-first',
|
||||
turnId
|
||||
});
|
||||
|
||||
return { role: null, cueTags: [] };
|
||||
}
|
||||
|
||||
isStructuralTag(tag) {
|
||||
const key = String(tag?.key || '').toLowerCase();
|
||||
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music'].includes(key);
|
||||
}
|
||||
|
||||
isTimedCueTag(tag) {
|
||||
const key = String(tag?.key || '').toLowerCase();
|
||||
return ['sfx', 'sound', 'audio'].includes(key);
|
||||
}
|
||||
|
||||
cueMarkersFromTags(tags) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
|
||||
return tags
|
||||
.filter(tag => this.isTimedCueTag(tag))
|
||||
.map(tag => {
|
||||
const filename = String(tag?.value || tag?.filename || '').trim();
|
||||
if (!filename) return null;
|
||||
const options = this.parseSfxTagOptions(tag?.param || tag?.options || '');
|
||||
return {
|
||||
type: 'sfx',
|
||||
...options,
|
||||
filename,
|
||||
url: this.resolveAssetUrl('sounds', filename),
|
||||
wordIndex: 0,
|
||||
charIndex: 0
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
blocksFromTags(tags, turnId = null) {
|
||||
const result = {
|
||||
blocks: [],
|
||||
paragraphRole: null
|
||||
};
|
||||
|
||||
if (!Array.isArray(tags)) return result;
|
||||
|
||||
tags.forEach((tag) => {
|
||||
const key = String(tag?.key || '').toLowerCase();
|
||||
const value = String(tag?.value || '').trim();
|
||||
const param = String(tag?.param || tag?.options || '').trim();
|
||||
|
||||
if ((key === 'chapter' || key === 'heading') && value) {
|
||||
result.blocks.push({
|
||||
type: 'heading',
|
||||
text: value,
|
||||
layoutText: value,
|
||||
role: 'chapter-heading',
|
||||
turnId
|
||||
});
|
||||
result.paragraphRole = 'chapter-first';
|
||||
} else if (key === 'section' || key === 'textblock') {
|
||||
result.blocks.push({
|
||||
type: 'heading',
|
||||
text: value || '* * *',
|
||||
layoutText: value || '* * *',
|
||||
role: 'section-heading',
|
||||
turnId
|
||||
});
|
||||
result.paragraphRole = 'textblock-first';
|
||||
} else if (key === 'image') {
|
||||
let filename = value;
|
||||
let optionText = param;
|
||||
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
|
||||
filename = param;
|
||||
optionText = value;
|
||||
}
|
||||
if (!filename) return;
|
||||
const options = this.parseImageTagOptions(optionText);
|
||||
const chapterOpening = result.paragraphRole === 'chapter-first';
|
||||
result.blocks.push({
|
||||
type: 'image',
|
||||
...options,
|
||||
floatSide: chapterOpening && String(options.size || '').toLowerCase() === 'portrait' ? 'right' : 'left',
|
||||
chapterOpening,
|
||||
filename,
|
||||
url: this.resolveAssetUrl('images', filename),
|
||||
turnId
|
||||
});
|
||||
} else if (key === 'music') {
|
||||
let filename = value;
|
||||
let optionText = param;
|
||||
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
|
||||
filename = param;
|
||||
optionText = value;
|
||||
}
|
||||
if (!filename) return;
|
||||
const options = this.parseMusicTagOptions(optionText);
|
||||
const leadInSeconds = Number(options.leadInSeconds);
|
||||
result.blocks.push({
|
||||
type: 'music',
|
||||
...options,
|
||||
leadInSeconds: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
||||
leadIn: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
||||
pause: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
|
||||
filename,
|
||||
url: this.resolveAssetUrl('music', filename),
|
||||
turnId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
enqueueStructuredBlock(block) {
|
||||
if (!block) return;
|
||||
|
||||
if (!this.textBuffer) {
|
||||
this.textBuffer = this.getModule('text-buffer');
|
||||
}
|
||||
|
||||
if (this.textBuffer && typeof this.textBuffer.addBlock === 'function') {
|
||||
console.log(`Socket Client: Queueing ${block.type} block`);
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'waiting-generating', reason: 'server-response-received' }
|
||||
}));
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
console.error('Socket Client: Text buffer not available');
|
||||
// Attempt to get text buffer again using parent's getModule method
|
||||
this.textBuffer = this.getModule('text-buffer');
|
||||
if (this.textBuffer) {
|
||||
this.textBuffer.addText(text);
|
||||
} else {
|
||||
// Emit a text event as fallback if no text buffer
|
||||
this.emitEvent('text', text);
|
||||
}
|
||||
this.textBuffer.addBlock(block);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Socket Client: Text buffer not available for structured block', block);
|
||||
}
|
||||
|
||||
parseImageTagOptions(optionText) {
|
||||
const parser = this.getModule('markup-parser');
|
||||
if (parser && typeof parser.parseImageOptions === 'function') {
|
||||
return parser.parseImageOptions(optionText);
|
||||
}
|
||||
return { size: 'landscape', leadInSeconds: 0 };
|
||||
}
|
||||
|
||||
parseSfxTagOptions(optionText) {
|
||||
const parser = this.getModule('markup-parser');
|
||||
if (parser && typeof parser.parseSfxOptions === 'function') {
|
||||
return parser.parseSfxOptions(optionText);
|
||||
}
|
||||
return { maxDurationSeconds: 0, endMode: 'stop', fadeDurationSeconds: 2 };
|
||||
}
|
||||
|
||||
parseMusicTagOptions(optionText) {
|
||||
const parser = this.getModule('markup-parser');
|
||||
if (parser && typeof parser.parseMusicOptions === 'function') {
|
||||
return parser.parseMusicOptions(optionText);
|
||||
}
|
||||
return { mode: 'crossfade', loop: true, leadInSeconds: 0 };
|
||||
}
|
||||
|
||||
resolveAssetUrl(kind, filename) {
|
||||
const parser = this.getModule('markup-parser');
|
||||
if (parser && typeof parser.resolveAssetUrl === 'function') {
|
||||
return parser.resolveAssetUrl(kind, filename);
|
||||
}
|
||||
|
||||
const root = kind === 'images' ? '/images/' : kind === 'music' ? '/music/' : '/sounds/';
|
||||
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
|
||||
return '';
|
||||
}
|
||||
return root + safeName.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
looksLikeAssetPath(value) {
|
||||
return /[./\\]/.test(String(value || '')) || /\.(png|jpe?g|gif|webp|svg|ogg|mp3|wav|m4a|flac)$/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,6 +581,10 @@ class SocketClientModule extends BaseModule {
|
||||
return this.callGameApi('saveGame', [slot]);
|
||||
}
|
||||
|
||||
chooseChoice(choiceIndex) {
|
||||
return this.callGameApi('chooseChoice', [choiceIndex]);
|
||||
}
|
||||
|
||||
hasSaveGame(slot = 1) {
|
||||
return this.callGameApi('hasSaveGame', [slot]);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class TextBufferModule extends BaseModule {
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'addText',
|
||||
'addBlock',
|
||||
'splitIntoParagraphs',
|
||||
'processNextFromQueue',
|
||||
'processSentences',
|
||||
@@ -135,6 +136,43 @@ class TextBufferModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an already parsed render block to the processing queue.
|
||||
* Engine protocols should prefer this over re-serializing tags into text markup.
|
||||
* @param {Object} block - Parsed paragraph/media/heading block
|
||||
*/
|
||||
addBlock(block) {
|
||||
if (!block || !block.type) return;
|
||||
|
||||
if (block.type === 'paragraph') {
|
||||
const paragraphId = block.id || `paragraph-${this.paragraphCounter + 1}`;
|
||||
this.processingQueue.push({
|
||||
...block,
|
||||
id: paragraphId,
|
||||
paragraphIndex: this.paragraphCounter,
|
||||
textBlockId: this.currentTextBlockId,
|
||||
text: String(block.text || '').trim(),
|
||||
layoutText: block.layoutText || block.text || ''
|
||||
});
|
||||
this.paragraphCounter += 1;
|
||||
} else {
|
||||
this.processingQueue.push({
|
||||
...block,
|
||||
id: block.id || `${block.type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
});
|
||||
|
||||
if (block.type === 'image') {
|
||||
this.currentTextBlockId += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isProcessingActive && this.onSentenceReadyCallback) {
|
||||
this.processNextFromQueue();
|
||||
} else {
|
||||
console.log(`TextBuffer: ${block.type} block queued for processing`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an incoming narrative fragment into book paragraphs.
|
||||
* Single newlines inside a paragraph are normalized to spaces; blank lines
|
||||
|
||||
@@ -239,7 +239,7 @@ class TextProcessorModule extends BaseModule {
|
||||
}
|
||||
|
||||
normalizeHyphenationLocale(locale) {
|
||||
const normalized = String(locale || 'en-us').toLowerCase();
|
||||
const normalized = String(locale || 'en-us').trim().toLowerCase().replace('_', '-');
|
||||
if (normalized === 'en') return 'en-us';
|
||||
if (normalized === 'de-de') return 'de';
|
||||
return normalized;
|
||||
|
||||
@@ -182,12 +182,11 @@ class TTSFactoryModule extends BaseModule {
|
||||
this.reportProgress(100, 'TTS Factory initialized');
|
||||
console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`);
|
||||
|
||||
// To maintain backward compatibility, we always return true
|
||||
// since TTS is now optional and the system should function without it
|
||||
// TTS is optional; the client must continue to function without it.
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('TTS Factory: Initialization error:', error);
|
||||
return true; // Still return true for backward compatibility
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +654,6 @@ class TTSFactoryModule extends BaseModule {
|
||||
detail: { handler: 'none', available: false }
|
||||
}));
|
||||
|
||||
// Also dispatch tts:engine:change for compatibility with Options UI
|
||||
document.dispatchEvent(new CustomEvent('tts:engine:change', {
|
||||
detail: { engine: 'none', handler: 'none', available: false }
|
||||
}));
|
||||
@@ -719,7 +717,6 @@ class TTSFactoryModule extends BaseModule {
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Also dispatch tts:engine:change for compatibility with Options UI
|
||||
document.dispatchEvent(new CustomEvent('tts:engine:change', {
|
||||
detail: { engine: id, handler: id, available: isReady }
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,7 @@ class UIControllerModule extends BaseModule {
|
||||
|
||||
// Remove 'tts' from direct dependencies to break circular dependency
|
||||
// UI Controller will access TTS through the Game Loop instead
|
||||
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator'];
|
||||
this.dependencies = ['animation-queue', 'ui-display-handler', 'ui-input-handler', 'ui-effects', 'text-buffer', 'socket-client', 'sentence-queue', 'playback-coordinator', 'persistence-manager'];
|
||||
|
||||
// References to sub-modules
|
||||
this.displayHandler = null;
|
||||
@@ -47,6 +47,8 @@ class UIControllerModule extends BaseModule {
|
||||
'syncTopControls',
|
||||
'getStoredTtsPreference',
|
||||
'setStoredTtsPreference',
|
||||
'getStoredAppPreference',
|
||||
'setStoredAppPreference',
|
||||
'sliderValueFromSpeed',
|
||||
'speedFromSliderValue',
|
||||
'initializeTextBuffer',
|
||||
@@ -267,7 +269,8 @@ class UIControllerModule extends BaseModule {
|
||||
}
|
||||
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
if (playbackCoordinator && playbackCoordinator.isPlaying) {
|
||||
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true';
|
||||
if ((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) {
|
||||
this.handleCommand({ type: 'continue', source: 'book-click' });
|
||||
}
|
||||
|
||||
@@ -350,6 +353,9 @@ class UIControllerModule extends BaseModule {
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
const { category, key, value } = event.detail || {};
|
||||
if (category !== 'tts') {
|
||||
if (category === 'app' && key === 'autoplay') {
|
||||
this.syncTopControls();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -391,6 +397,7 @@ class UIControllerModule extends BaseModule {
|
||||
|
||||
bindTopControls() {
|
||||
const speechToggle = document.getElementById('speech');
|
||||
const autoplayToggle = document.getElementById('autoplay');
|
||||
const speedSlider = document.getElementById('speed');
|
||||
const speedReset = document.getElementById('speed_reset');
|
||||
|
||||
@@ -428,6 +435,22 @@ class UIControllerModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
if (autoplayToggle && autoplayToggle.dataset.uiControllerBound !== 'true') {
|
||||
autoplayToggle.dataset.uiControllerBound = 'true';
|
||||
autoplayToggle.removeAttribute('disabled');
|
||||
autoplayToggle.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const nextAutoplay = !this.getStoredAppPreference('autoplay', true);
|
||||
this.setStoredAppPreference('autoplay', nextAutoplay);
|
||||
console.log(`UIController: Autoplay set to ${nextAutoplay ? 'enabled' : 'disabled'}`);
|
||||
this.syncTopControls();
|
||||
document.dispatchEvent(new CustomEvent('app:autoplay:change', {
|
||||
detail: { enabled: nextAutoplay, source: 'topbar' }
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (speedSlider && speedSlider.dataset.uiControllerBound !== 'true') {
|
||||
speedSlider.dataset.uiControllerBound = 'true';
|
||||
speedSlider.min = speedSlider.min || '50';
|
||||
@@ -518,6 +541,48 @@ class UIControllerModule extends BaseModule {
|
||||
console.warn('UIController: Failed to write TTS preference fallback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getStoredAppPreference(key, defaultValue) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.getPreference === 'function') {
|
||||
const value = persistenceManager.getPreference('app', key, undefined);
|
||||
if (typeof value !== 'undefined' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem('ai-interactive-fiction-preferences');
|
||||
if (raw) {
|
||||
const prefs = JSON.parse(raw);
|
||||
if (prefs && prefs.app && Object.prototype.hasOwnProperty.call(prefs.app, key)) {
|
||||
return prefs.app[key];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('UIController: Failed to read app preference fallback:', error);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
setStoredAppPreference(key, value) {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager && typeof persistenceManager.updatePreference === 'function') {
|
||||
persistenceManager.updatePreference('app', key, value);
|
||||
}
|
||||
|
||||
try {
|
||||
const storageKey = 'ai-interactive-fiction-preferences';
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const prefs = raw ? JSON.parse(raw) : {};
|
||||
prefs.app = prefs.app || {};
|
||||
prefs.app[key] = value;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
} catch (error) {
|
||||
console.warn('UIController: Failed to write app preference fallback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async setupMainUI() {
|
||||
// Ensure all UI components exist
|
||||
@@ -583,6 +648,9 @@ class UIControllerModule extends BaseModule {
|
||||
break;
|
||||
case 'continue':
|
||||
{
|
||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
|
||||
}));
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
if (playbackCoordinator && playbackCoordinator.isPlaying) {
|
||||
playbackCoordinator.fastForward();
|
||||
@@ -633,6 +701,7 @@ class UIControllerModule extends BaseModule {
|
||||
const loadButton = document.getElementById('reload');
|
||||
const restartButton = document.getElementById('rewind');
|
||||
const speechToggle = document.getElementById('speech');
|
||||
const autoplayToggle = document.getElementById('autoplay');
|
||||
|
||||
// Update save button state
|
||||
if (saveButton && typeof canSave === 'boolean') {
|
||||
@@ -679,6 +748,13 @@ class UIControllerModule extends BaseModule {
|
||||
speechToggle.title = 'Enable speech';
|
||||
}
|
||||
}
|
||||
|
||||
if (autoplayToggle) {
|
||||
const autoplay = this.getStoredAppPreference('autoplay', true) !== false;
|
||||
autoplayToggle.removeAttribute('disabled');
|
||||
autoplayToggle.style.fontWeight = autoplay ? 'bold' : 'normal';
|
||||
autoplayToggle.style.color = autoplay ? '#000' : '#999';
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
|
||||
@@ -9,7 +9,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// Module dependencies
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator'];
|
||||
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization'];
|
||||
|
||||
// DOM elements
|
||||
this.container = null;
|
||||
@@ -31,10 +31,19 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
'initializeContainers',
|
||||
'applyGameConfig',
|
||||
'applyTranslations',
|
||||
'displayText',
|
||||
'renderSentence',
|
||||
'handleDeferredMediaBlock',
|
||||
'renderImageBlock',
|
||||
'calculateImageMetrics',
|
||||
'readFirstFiniteNumber',
|
||||
'waitForSkippablePause',
|
||||
'scrollStoryToEnd',
|
||||
'animatePageScroll',
|
||||
'scrollToTurn',
|
||||
'handleStoryScroll',
|
||||
'rerenderStory',
|
||||
'clear',
|
||||
'scheduleRerender',
|
||||
@@ -46,6 +55,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
console.log('UIDisplayHandler: Constructor initialized');
|
||||
}
|
||||
|
||||
t(key, params = {}) {
|
||||
return this.localization?.translate?.(key, params) || key;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
@@ -61,6 +74,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
// Get references to required modules using parent's getModule method
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
this.gameConfig = this.getModule('game-config');
|
||||
this.localization = this.getModule('localization');
|
||||
|
||||
this.reportProgress(50, "Initializing display containers");
|
||||
|
||||
@@ -73,6 +88,24 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.addEventListener(document, 'book:resized', () => {
|
||||
this.scheduleRerender();
|
||||
});
|
||||
this.addEventListener(document, 'game:config', (event) => {
|
||||
this.applyGameConfig(event.detail);
|
||||
});
|
||||
this.addEventListener(document, 'localization:languageChanged', () => {
|
||||
this.applyTranslations();
|
||||
});
|
||||
this.addEventListener(document, 'story:scroll-to-turn', (event) => {
|
||||
this.scrollToTurn(event.detail?.turnId);
|
||||
});
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
const state = event.detail?.state || 'ready';
|
||||
const remark = document.getElementById('remark_text');
|
||||
if (remark) {
|
||||
remark.textContent = state === 'paused'
|
||||
? this.t('title.continueHint')
|
||||
: this.t('title.fastForwardHint');
|
||||
}
|
||||
});
|
||||
|
||||
if (window.ResizeObserver && this.paragraphContainer) {
|
||||
this.storyResizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -196,9 +229,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'header';
|
||||
header.innerHTML = `
|
||||
<h2 class="byline l10n-by">powered by Generative AI</h2>
|
||||
<h1 class="title">AI Interactive Fiction</h1>
|
||||
<h3 class="subtitle">An open-world text adventure</h3>
|
||||
<h2 class="byline" id="game_author"></h2>
|
||||
<h1 class="title" id="game_title"></h1>
|
||||
<h3 class="subtitle" id="game_subtitle"></h3>
|
||||
<div class="separator"><double>❦</double></div>
|
||||
`;
|
||||
this.pageLeft.appendChild(header);
|
||||
@@ -208,12 +241,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
controls.id = 'controls';
|
||||
controls.className = 'buttons';
|
||||
controls.innerHTML = `
|
||||
<a class="l10n-speech" id="speech" title="Toggle text to speech">speech</a>
|
||||
<span><a id="speed_reset"><span class="l10n-speed">speed<sup>*</sup></span></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
||||
<a class="l10n-restart" id="rewind" title="Start a new game">new game</a>
|
||||
<a class="l10n-save" id="save" title="Save progress">save</a>
|
||||
<a class="l10n-load" id="reload" title="Reload from save point" disabled="disabled">load</a>
|
||||
<a class="l10n-options" id="options" title="Options">options</a>
|
||||
<a id="speech"></a>
|
||||
<a id="autoplay"></a>
|
||||
<span><a id="speed_reset"><span id="speed_label"></span><sup>*</sup></a><input type="range" min="50" max="150" value="100" id="speed" name="speed" /></span>
|
||||
<a id="rewind"></a>
|
||||
<a id="save"></a>
|
||||
<a id="reload" disabled="disabled"></a>
|
||||
<a id="options"></a>
|
||||
`;
|
||||
this.pageLeft.appendChild(controls);
|
||||
|
||||
@@ -232,7 +266,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
commandInput.id = 'command_input';
|
||||
commandInput.innerHTML = `
|
||||
<div class="input-wrapper">
|
||||
<textarea id="player_input" placeholder="Enter your command..." rows="1" autofocus></textarea>
|
||||
<textarea id="player_input" rows="1" autofocus autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="true" aria-autocomplete="none" data-form-type="other" data-1p-ignore="true" data-lpignore="true" data-bwignore="true"></textarea>
|
||||
<span id="cursor"></span>
|
||||
</div>
|
||||
`;
|
||||
@@ -243,8 +277,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
// Create remark
|
||||
const remark = document.createElement('div');
|
||||
remark.id = 'remark';
|
||||
remark.className = 'l10n-remark';
|
||||
remark.innerHTML = '<i><sup>*</sup>click on page or press spacebar to fast forward text animation</i>';
|
||||
remark.innerHTML = `
|
||||
<div id="remark_hint"><i><sup>*</sup><span id="remark_text"></span></i></div>
|
||||
<div id="game_legal"></div>
|
||||
`;
|
||||
this.pageLeft.appendChild(remark);
|
||||
|
||||
bookContainer.appendChild(this.pageLeft);
|
||||
@@ -272,7 +308,6 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
if (!document.getElementById('start_prompt')) {
|
||||
const startPrompt = document.createElement('div');
|
||||
startPrompt.id = 'start_prompt';
|
||||
startPrompt.textContent = 'Klick on new game or load to start the game';
|
||||
this.pageRight.appendChild(startPrompt);
|
||||
}
|
||||
|
||||
@@ -302,6 +337,66 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
console.log('UIDisplayHandler: All containers initialized');
|
||||
this.applyGameConfig(this.gameConfig?.getConfig?.());
|
||||
this.applyTranslations();
|
||||
if (this.pageRight && !this.pageRight.dataset.turnScrollBound) {
|
||||
this.pageRight.dataset.turnScrollBound = 'true';
|
||||
this.pageRight.addEventListener('scroll', this.handleStoryScroll, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
applyGameConfig(config) {
|
||||
const metadata = config?.metadata || this.gameConfig?.getMetadata?.() || {};
|
||||
const titleElement = document.getElementById('game_title');
|
||||
const authorElement = document.getElementById('game_author');
|
||||
const subtitleElement = document.getElementById('game_subtitle');
|
||||
const legalElement = document.getElementById('game_legal');
|
||||
document.getElementById('game_version')?.remove();
|
||||
document.getElementById('game_copyright')?.remove();
|
||||
|
||||
if (titleElement) titleElement.textContent = metadata.title || '';
|
||||
if (authorElement) authorElement.textContent = metadata.author ? this.t('title.byAuthor', { author: metadata.author }) : '';
|
||||
if (subtitleElement) subtitleElement.textContent = metadata.subtitle || '';
|
||||
if (legalElement) {
|
||||
const items = [
|
||||
metadata.version ? this.t('title.version', { version: metadata.version }) : '',
|
||||
metadata.copyright || ''
|
||||
].filter(Boolean);
|
||||
legalElement.textContent = items.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
applyTranslations() {
|
||||
this.localization = this.getModule('localization') || this.localization;
|
||||
|
||||
const setText = (id, key) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.textContent = this.t(key);
|
||||
};
|
||||
const setTitle = (id, key) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.setAttribute('title', this.t(key));
|
||||
};
|
||||
|
||||
setText('speech', 'topbar.speech');
|
||||
setText('autoplay', 'topbar.autoplay');
|
||||
setText('speed_label', 'topbar.speed');
|
||||
setText('rewind', 'topbar.newGame');
|
||||
setText('save', 'topbar.save');
|
||||
setText('reload', 'topbar.load');
|
||||
setText('options', 'topbar.options');
|
||||
setText('remark_text', 'title.fastForwardHint');
|
||||
setText('start_prompt', 'title.startPrompt');
|
||||
setTitle('speech', 'topbar.speechTitle');
|
||||
setTitle('autoplay', 'topbar.autoplayTitle');
|
||||
setTitle('rewind', 'topbar.newGameTitle');
|
||||
setTitle('save', 'topbar.saveTitle');
|
||||
setTitle('reload', 'topbar.loadTitle');
|
||||
setTitle('options', 'topbar.optionsTitle');
|
||||
|
||||
const input = document.getElementById('player_input');
|
||||
if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
|
||||
this.applyGameConfig(this.gameConfig?.getConfig?.());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,17 +422,13 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
|
||||
/**
|
||||
* Display text in the UI (backward compatibility)
|
||||
* Note: Text should flow through SentenceQueue instead
|
||||
* @param {string} text - Text to display
|
||||
* @param {Object} options - Display options
|
||||
* @returns {Promise<HTMLElement>} - Promise resolving to the displayed paragraph element
|
||||
* Display a local UI message outside the server turn protocol.
|
||||
* Story output must flow through structured TurnResult objects instead.
|
||||
*/
|
||||
displayText(text, options = {}) {
|
||||
if (!text) return Promise.resolve(null);
|
||||
|
||||
// For backward compatibility, delegate to sentence queue
|
||||
console.warn('UIDisplayHandler.displayText called directly, text should flow through SentenceQueue');
|
||||
console.warn('UIDisplayHandler.displayText called directly; story text should come from TurnResult');
|
||||
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
if (sentenceQueue) {
|
||||
@@ -369,6 +460,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
sentence.layout,
|
||||
{ id: sentence.id }
|
||||
);
|
||||
if (sentence.turnId != null) {
|
||||
paragraphElement.dataset.turnId = String(sentence.turnId);
|
||||
paragraphElement.classList.add('story-turn-block');
|
||||
}
|
||||
|
||||
// Append to container
|
||||
if (this.paragraphContainer) {
|
||||
@@ -386,6 +481,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.renderedItems.push({
|
||||
type: sentence.kind === 'heading' ? 'heading' : 'paragraph',
|
||||
id: sentence.id,
|
||||
turnId: sentence.turnId ?? null,
|
||||
text: sentence.text,
|
||||
metadata: {
|
||||
layoutText: sentence.layout?.sourceLayoutText || sentence.text,
|
||||
@@ -427,9 +523,25 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.paragraphContainer.innerHTML = '';
|
||||
|
||||
for (const item of this.renderedItems) {
|
||||
if (item.type === 'image') {
|
||||
const sentenceQueue = this.getModule('sentence-queue');
|
||||
const imageLayout = sentenceQueue && typeof sentenceQueue.prepareImageLayout === 'function'
|
||||
? await sentenceQueue.prepareImageLayout(item.metadata || {})
|
||||
: null;
|
||||
this.renderImageBlock({
|
||||
...(item.metadata || {}),
|
||||
imageLayout: imageLayout || item.metadata?.imageLayout
|
||||
}, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === 'heading') {
|
||||
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
||||
const heading = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
if (item.turnId != null) {
|
||||
heading.dataset.turnId = String(item.turnId);
|
||||
heading.classList.add('story-turn-block');
|
||||
}
|
||||
heading.querySelectorAll('.word').forEach(word => {
|
||||
word.style.transition = 'none';
|
||||
word.style.animation = 'none';
|
||||
@@ -446,6 +558,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
const layout = await sentenceQueue.prepareLayout(item.text, item.metadata || {});
|
||||
const paragraph = this.layoutRenderer.renderParagraph(layout, { id: item.id });
|
||||
if (item.turnId != null) {
|
||||
paragraph.dataset.turnId = String(item.turnId);
|
||||
paragraph.classList.add('story-turn-block');
|
||||
}
|
||||
paragraph.querySelectorAll('.word').forEach(word => {
|
||||
word.style.transition = 'none';
|
||||
word.style.animation = 'none';
|
||||
@@ -468,13 +584,74 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
this.pageRight.scrollTo({
|
||||
top: Math.max(0, this.pageRight.scrollHeight - this.pageRight.clientHeight),
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
});
|
||||
this.animatePageScroll(
|
||||
Math.max(0, this.pageRight.scrollHeight - this.pageRight.clientHeight),
|
||||
smooth ? 720 : 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
animatePageScroll(targetTop, duration = 720) {
|
||||
if (!this.pageRight) return;
|
||||
if (!duration) {
|
||||
this.pageRight.scrollTop = targetTop;
|
||||
return;
|
||||
}
|
||||
|
||||
const startTop = this.pageRight.scrollTop;
|
||||
const delta = targetTop - startTop;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
|
||||
const startedAt = performance.now();
|
||||
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
||||
const step = (now) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration);
|
||||
this.pageRight.scrollTop = startTop + (delta * ease(progress));
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
scrollToTurn(turnId) {
|
||||
if (!this.pageRight || turnId == null) return;
|
||||
const escapedTurnId = CSS.escape(String(turnId));
|
||||
const target = this.paragraphContainer?.querySelector(`[data-turn-id="${escapedTurnId}"]`);
|
||||
if (!target) return;
|
||||
this.pageRight.scrollTo({
|
||||
top: Math.max(0, target.offsetTop - 12),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
handleStoryScroll() {
|
||||
if (!this.pageRight || !this.paragraphContainer) return;
|
||||
|
||||
const blocks = Array.from(this.paragraphContainer.querySelectorAll('[data-turn-id]'));
|
||||
if (blocks.length === 0) return;
|
||||
|
||||
const viewportMiddle = this.pageRight.scrollTop + (this.pageRight.clientHeight / 2);
|
||||
let best = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
blocks.forEach((block) => {
|
||||
const blockMiddle = block.offsetTop + (block.offsetHeight / 2);
|
||||
const distance = Math.abs(blockMiddle - viewportMiddle);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = block;
|
||||
}
|
||||
});
|
||||
|
||||
if (best?.dataset?.turnId && this.activeTurnId !== best.dataset.turnId) {
|
||||
this.activeTurnId = best.dataset.turnId;
|
||||
document.dispatchEvent(new CustomEvent('story:visible-turn', {
|
||||
detail: { turnId: Number(best.dataset.turnId) }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeferredMediaBlock(sentence) {
|
||||
document.dispatchEvent(new CustomEvent('story:media-block', {
|
||||
detail: {
|
||||
@@ -484,12 +661,27 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
}));
|
||||
|
||||
if (sentence.kind === 'music') {
|
||||
const leadInSeconds = Number(sentence.metadata?.leadInSeconds || sentence.metadata?.leadIn || 0);
|
||||
if (leadInSeconds > 0) {
|
||||
console.log(`UIDisplayHandler: Waiting ${leadInSeconds}s before continuing after music block`);
|
||||
await new Promise(resolve => setTimeout(resolve, leadInSeconds * 1000));
|
||||
if (sentence.kind === 'image') {
|
||||
const element = this.renderImageBlock(sentence.metadata || {}, true);
|
||||
this.renderedItems.push({
|
||||
type: 'image',
|
||||
id: sentence.id,
|
||||
turnId: sentence.turnId ?? null,
|
||||
text: '',
|
||||
metadata: sentence.metadata || {}
|
||||
});
|
||||
|
||||
this.scrollStoryToEnd(true);
|
||||
|
||||
if (sentence.onComplete) {
|
||||
sentence.onComplete();
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
if (sentence.kind === 'music') {
|
||||
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
||||
}
|
||||
|
||||
if (sentence.onComplete) {
|
||||
@@ -499,7 +691,145 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
return null;
|
||||
}
|
||||
|
||||
readFirstFiniteNumber(...values) {
|
||||
for (const value of values) {
|
||||
const number = Number(value);
|
||||
if (Number.isFinite(number)) {
|
||||
return Math.max(0, number);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
waitForSkippablePause(seconds, kind = 'media') {
|
||||
const duration = Math.max(0, Number(seconds) || 0) * 1000;
|
||||
if (duration <= 0) return Promise.resolve(false);
|
||||
|
||||
document.documentElement.dataset.skippablePause = 'true';
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: `${kind}-pause-start`, duration }
|
||||
}));
|
||||
|
||||
return new Promise(resolve => {
|
||||
let finished = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const finish = (skipped) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('ui:command', onCommand);
|
||||
delete document.documentElement.dataset.skippablePause;
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: `${kind}-pause-${skipped ? 'skipped' : 'complete'}` }
|
||||
}));
|
||||
resolve(skipped);
|
||||
};
|
||||
|
||||
const onCommand = (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
finish(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('ui:command', onCommand);
|
||||
timeoutId = setTimeout(() => finish(false), duration);
|
||||
});
|
||||
}
|
||||
|
||||
renderImageBlock(metadata = {}, animate = true) {
|
||||
if (!this.paragraphContainer) return null;
|
||||
|
||||
const metrics = metadata.imageLayout || this.calculateImageMetrics(metadata.size);
|
||||
const figure = document.createElement('figure');
|
||||
figure.className = [
|
||||
'story-image-block',
|
||||
`story-image-${metrics.size || 'landscape'}`,
|
||||
metrics.floatSide === 'right' ? 'story-image-float-right' : '',
|
||||
metrics.floatSide === 'left' ? 'story-image-float-left' : '',
|
||||
animate ? 'story-image-pending' : 'story-image-visible'
|
||||
].filter(Boolean).join(' ');
|
||||
figure.style.width = `${metrics.width}px`;
|
||||
figure.style.height = `${metrics.height}px`;
|
||||
figure.style.marginTop = `${metrics.verticalMargin || 0}px`;
|
||||
figure.style.marginBottom = `${metrics.verticalMargin || 0}px`;
|
||||
figure.dataset.animationMs = '900';
|
||||
if (metadata.turnId != null) {
|
||||
figure.dataset.turnId = String(metadata.turnId);
|
||||
figure.classList.add('story-turn-block');
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = metadata.url || metadata.filename || '';
|
||||
img.alt = metadata.alt || '';
|
||||
img.decoding = 'async';
|
||||
img.loading = 'eager';
|
||||
figure.appendChild(img);
|
||||
|
||||
this.paragraphContainer.appendChild(figure);
|
||||
|
||||
if (animate) {
|
||||
window.requestAnimationFrame(() => {
|
||||
figure.classList.remove('story-image-pending');
|
||||
figure.classList.add('story-image-visible');
|
||||
});
|
||||
} else {
|
||||
figure.classList.remove('story-image-pending');
|
||||
figure.classList.add('story-image-visible');
|
||||
}
|
||||
|
||||
return figure;
|
||||
}
|
||||
|
||||
calculateImageMetrics(size = 'landscape') {
|
||||
const storyElement = document.getElementById('story');
|
||||
const pageWidth = storyElement?.clientWidth || 600;
|
||||
const probe = document.createElement('p');
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.left = '-8000px';
|
||||
probe.style.top = '-8000px';
|
||||
(storyElement || document.body).appendChild(probe);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(probe).lineHeight) || 24;
|
||||
probe.remove();
|
||||
|
||||
const normalizedSize = String(size || 'landscape').toLowerCase() === 'widescreen'
|
||||
? 'landscape'
|
||||
: String(size || 'landscape').toLowerCase();
|
||||
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
|
||||
const imageGap = lineHeight * 0.9;
|
||||
const maxWidth = normalizedSize === 'portrait' ? pageWidth * 0.5 : pageWidth;
|
||||
const naturalHeight = maxWidth / aspect;
|
||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||
const height = imageLineCount * lineHeight;
|
||||
const width = Math.min(maxWidth, height * aspect);
|
||||
const verticalMargin = lineHeight / 2;
|
||||
const lineCount = imageLineCount + 1;
|
||||
|
||||
return {
|
||||
size: normalizedSize,
|
||||
aspect,
|
||||
width,
|
||||
height,
|
||||
gap: imageGap,
|
||||
lineCount,
|
||||
imageLineCount,
|
||||
lineHeight,
|
||||
verticalMargin,
|
||||
floatSide: 'left',
|
||||
pageWidth
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (document.documentElement.dataset.skippablePause === 'true') {
|
||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }
|
||||
}));
|
||||
delete document.documentElement.dataset.skippablePause;
|
||||
}
|
||||
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
this.paragraphContainer = document.createElement('div');
|
||||
|
||||
@@ -18,6 +18,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
this.historyIndex = -1;
|
||||
this.commandHistory = [];
|
||||
this.inputBuffer = '';
|
||||
this.inputMode = 'text';
|
||||
|
||||
// Bind methods using the parent class bindMethods utility
|
||||
this.bindMethods([
|
||||
@@ -28,11 +29,14 @@ class UIInputHandlerModule extends BaseModule {
|
||||
'handleKeyboardInput',
|
||||
'submitCommand',
|
||||
'addToHistory',
|
||||
'bindHistoryToTurn',
|
||||
'highlightHistoryTurn',
|
||||
'formatCommandHistory',
|
||||
'resetCursorPosition',
|
||||
'focusInput',
|
||||
'setProcessState',
|
||||
'setInputAvailability',
|
||||
'setMode',
|
||||
'clearHistory'
|
||||
]);
|
||||
|
||||
@@ -61,6 +65,15 @@ class UIInputHandlerModule extends BaseModule {
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
this.setProcessState(event.detail?.state || 'ready', event.detail || {});
|
||||
});
|
||||
this.addEventListener(document, 'story:input-mode', (event) => {
|
||||
this.setMode(event.detail || 'text');
|
||||
});
|
||||
this.addEventListener(document, 'story:turn-start', (event) => {
|
||||
this.bindHistoryToTurn(event.detail?.turnId);
|
||||
});
|
||||
this.addEventListener(document, 'story:visible-turn', (event) => {
|
||||
this.highlightHistoryTurn(event.detail?.turnId);
|
||||
});
|
||||
|
||||
this.reportProgress(100, 'UI Input Handler ready');
|
||||
return true;
|
||||
@@ -87,7 +100,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === ' ' && this.isPlaybackActive()) {
|
||||
if (event.key === ' ' && (this.isPlaybackActive() || this.isSkippablePauseActive())) {
|
||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||
detail: { type: 'continue', source: 'spacebar' }
|
||||
}));
|
||||
@@ -95,7 +108,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (document.body.dataset.gameRunning !== 'true') {
|
||||
if (document.body.dataset.gameRunning !== 'true' || this.inputMode !== 'text') {
|
||||
return;
|
||||
}
|
||||
this.submitCommand();
|
||||
@@ -110,6 +123,9 @@ class UIInputHandlerModule extends BaseModule {
|
||||
if (document.body.dataset.gameRunning !== 'true') {
|
||||
return;
|
||||
}
|
||||
if (this.inputMode !== 'text') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.focusInput();
|
||||
const start = this.playerInput.selectionStart ?? this.playerInput.value.length;
|
||||
@@ -176,8 +192,6 @@ class UIInputHandlerModule extends BaseModule {
|
||||
playerInput.id = 'player_input';
|
||||
playerInput.rows = 1;
|
||||
playerInput.placeholder = 'What will you do?';
|
||||
playerInput.setAttribute('autocomplete', 'off');
|
||||
playerInput.setAttribute('spellcheck', 'true');
|
||||
|
||||
// Fix horizontal scrolling by ensuring the textbox wraps text
|
||||
playerInput.style.overflowX = 'hidden';
|
||||
@@ -187,6 +201,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
inputWrapper.appendChild(playerInput);
|
||||
}
|
||||
this.playerInput = playerInput;
|
||||
this.applyTextInputAttributes(playerInput);
|
||||
|
||||
// Create the cursor if needed
|
||||
let cursor = document.getElementById('cursor');
|
||||
@@ -260,7 +275,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
setInputAvailability(enabled) {
|
||||
this.inputEnabled = Boolean(enabled);
|
||||
this.inputEnabled = Boolean(enabled) && this.inputMode === 'text';
|
||||
const commandInput = document.getElementById('command_input');
|
||||
if (commandInput) {
|
||||
commandInput.classList.toggle('fading', !this.inputEnabled);
|
||||
@@ -276,6 +291,31 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
applyTextInputAttributes(playerInput) {
|
||||
if (!playerInput) return;
|
||||
|
||||
const attributes = {
|
||||
autocomplete: 'off',
|
||||
autocorrect: 'off',
|
||||
autocapitalize: 'sentences',
|
||||
spellcheck: 'true',
|
||||
'aria-autocomplete': 'none',
|
||||
'data-form-type': 'other',
|
||||
'data-1p-ignore': 'true',
|
||||
'data-lpignore': 'true',
|
||||
'data-bwignore': 'true'
|
||||
};
|
||||
|
||||
Object.entries(attributes).forEach(([name, value]) => {
|
||||
playerInput.setAttribute(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.inputMode = ['text', 'choice', 'end'].includes(mode) ? mode : 'text';
|
||||
this.setInputAvailability(this.inputMode === 'text');
|
||||
}
|
||||
|
||||
applyMouseCursor(state) {
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
@@ -365,6 +405,7 @@ class UIInputHandlerModule extends BaseModule {
|
||||
submitCommand() {
|
||||
if (!this.playerInput || !this.playerInput.value.trim()) return;
|
||||
if (document.body.dataset.gameRunning !== 'true' || !this.inputEnabled) return;
|
||||
if (this.inputMode !== 'text') return;
|
||||
|
||||
const command = this.playerInput.value.trim();
|
||||
console.log(`UIInputHandler: Submitting command: "${command}"`);
|
||||
@@ -420,7 +461,15 @@ class UIInputHandlerModule extends BaseModule {
|
||||
if (this.commandHistoryElement && this.commandHistoryElement.appendChild) {
|
||||
const historyItem = document.createElement('div');
|
||||
historyItem.className = 'history-item';
|
||||
historyItem.dataset.turnId = 'pending';
|
||||
historyItem.innerHTML = `> ${this.formatCommandHistory(command)}`;
|
||||
historyItem.addEventListener('click', () => {
|
||||
const turnId = historyItem.dataset.turnId;
|
||||
if (!turnId || turnId === 'pending') return;
|
||||
document.dispatchEvent(new CustomEvent('story:scroll-to-turn', {
|
||||
detail: { turnId: Number(turnId) }
|
||||
}));
|
||||
});
|
||||
this.commandHistoryElement.appendChild(historyItem);
|
||||
|
||||
// Limit visible history items
|
||||
@@ -433,6 +482,25 @@ class UIInputHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
bindHistoryToTurn(turnId) {
|
||||
if (!Number.isInteger(Number(turnId))) return;
|
||||
if (!this.commandHistoryElement) {
|
||||
this.commandHistoryElement = document.getElementById('command_history');
|
||||
}
|
||||
const pending = this.commandHistoryElement?.querySelector('.history-item[data-turn-id="pending"]');
|
||||
if (!pending) return;
|
||||
pending.dataset.turnId = String(turnId);
|
||||
pending.classList.remove('history-pending');
|
||||
}
|
||||
|
||||
highlightHistoryTurn(turnId) {
|
||||
if (!this.commandHistoryElement || turnId == null) return;
|
||||
const id = String(turnId);
|
||||
this.commandHistoryElement.querySelectorAll('.history-item').forEach((item) => {
|
||||
item.classList.toggle('active', item.dataset.turnId === id);
|
||||
});
|
||||
}
|
||||
|
||||
formatCommandHistory(command) {
|
||||
const parser = this.getModule('markup-parser') || window.MarkupParser;
|
||||
if (parser && typeof parser.markdownToHtml === 'function') {
|
||||
@@ -450,6 +518,10 @@ class UIInputHandlerModule extends BaseModule {
|
||||
return Boolean(playbackCoordinator && playbackCoordinator.isPlaying);
|
||||
}
|
||||
|
||||
isSkippablePauseActive() {
|
||||
return document.documentElement.dataset.skippablePause === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cursor position to the start.
|
||||
*/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title.byAuthor": "von {{author}}",
|
||||
"title.version": "Version {{version}}",
|
||||
"title.fastForwardHint": "auf die Seite klicken oder Leertaste druecken, um die Textanimation vorzuspulen",
|
||||
"title.continueHint": "auf die Seite klicken oder Leertaste druecken, um fortzufahren",
|
||||
"title.startPrompt": "Klicke auf Neues Spiel oder Laden, um das Spiel zu starten",
|
||||
"topbar.speech": "Sprache",
|
||||
"topbar.autoplay": "Auto",
|
||||
"topbar.speed": "Tempo",
|
||||
"topbar.newGame": "Neues Spiel",
|
||||
"topbar.save": "Speichern",
|
||||
"topbar.load": "Laden",
|
||||
"topbar.options": "Optionen",
|
||||
"topbar.speechTitle": "Sprachausgabe ein- oder ausschalten",
|
||||
"topbar.autoplayTitle": "Automatisches Abspielen absatzweise ein- oder ausschalten",
|
||||
"topbar.newGameTitle": "Neues Spiel starten",
|
||||
"topbar.saveTitle": "Fortschritt speichern",
|
||||
"topbar.loadTitle": "Gespeicherten Fortschritt laden",
|
||||
"topbar.optionsTitle": "Optionen",
|
||||
"input.placeholder": "Befehl eingeben...",
|
||||
"options.title": "Optionen",
|
||||
"options.close": "Schliessen",
|
||||
"options.applicationSettings": "Anwendung",
|
||||
"options.language": "Sprache",
|
||||
"options.speech": "Sprachausgabe",
|
||||
"options.enableSpeech": "Sprachausgabe aktivieren",
|
||||
"options.provider": "Anbieter",
|
||||
"options.voice": "Stimme",
|
||||
"options.speed": "Tempo",
|
||||
"options.audio": "Audio",
|
||||
"options.volume": "Lautstaerke",
|
||||
"options.masterVolume": "Gesamtlautstaerke",
|
||||
"options.speechVolume": "Sprachlautstaerke",
|
||||
"options.musicVolume": "Musiklautstaerke",
|
||||
"options.sfxVolume": "Effektlautstaerke",
|
||||
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
|
||||
"options.openAiSettings": "OpenAI API-Einstellungen",
|
||||
"options.apiKey": "API-Schluessel",
|
||||
"options.apiUrl": "API-URL"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"title.byAuthor": "by {{author}}",
|
||||
"title.version": "Version {{version}}",
|
||||
"title.fastForwardHint": "click on page or press spacebar to fast forward text animation",
|
||||
"title.continueHint": "click on page or press spacebar to continue",
|
||||
"title.startPrompt": "Click on new game or load to start the game",
|
||||
"topbar.speech": "speech",
|
||||
"topbar.autoplay": "autoplay",
|
||||
"topbar.speed": "speed",
|
||||
"topbar.newGame": "new game",
|
||||
"topbar.save": "save",
|
||||
"topbar.load": "load",
|
||||
"topbar.options": "options",
|
||||
"topbar.speechTitle": "Toggle text to speech",
|
||||
"topbar.autoplayTitle": "Toggle paragraph autoplay",
|
||||
"topbar.newGameTitle": "Start a new game",
|
||||
"topbar.saveTitle": "Save progress",
|
||||
"topbar.loadTitle": "Load saved progress",
|
||||
"topbar.optionsTitle": "Options",
|
||||
"input.placeholder": "Enter your command...",
|
||||
"options.title": "Options",
|
||||
"options.close": "Close",
|
||||
"options.applicationSettings": "Application Settings",
|
||||
"options.language": "Language",
|
||||
"options.speech": "Speech",
|
||||
"options.enableSpeech": "Enable text to speech",
|
||||
"options.provider": "Provider",
|
||||
"options.voice": "Voice",
|
||||
"options.speed": "Speed",
|
||||
"options.audio": "Audio",
|
||||
"options.volume": "Volume",
|
||||
"options.masterVolume": "Master Volume",
|
||||
"options.speechVolume": "Speech Volume",
|
||||
"options.musicVolume": "Music Volume",
|
||||
"options.sfxVolume": "Sound Effects Volume",
|
||||
"options.elevenLabsSettings": "ElevenLabs API Settings",
|
||||
"options.openAiSettings": "OpenAI API Settings",
|
||||
"options.apiKey": "API Key",
|
||||
"options.apiUrl": "API URL"
|
||||
}
|
||||
@@ -6,13 +6,7 @@ Story music paths resolve relative to this directory.
|
||||
Block music markup:
|
||||
|
||||
```text
|
||||
::music[crossfade, loop, lead=4](track-name.ogg)
|
||||
```
|
||||
|
||||
Inline music cue:
|
||||
|
||||
```text
|
||||
The candles gutter. {{music:cut:danger.ogg}} Something moves upstairs.
|
||||
#music[track-name.ogg](crossfade, loop, lead=4)
|
||||
```
|
||||
|
||||
Supported modes:
|
||||
|
||||
@@ -3,13 +3,14 @@ Sound Effects
|
||||
|
||||
Story sound effect paths resolve relative to this directory.
|
||||
|
||||
Use inline sound effect markup inside narrative text:
|
||||
Use a sound effect story tag:
|
||||
|
||||
```text
|
||||
The old door opens {{sfx:squeaky-door.ogg}} into the dark.
|
||||
#sfx[squeaky-door.ogg]
|
||||
The old door opens into the dark.
|
||||
```
|
||||
|
||||
The marker is removed from displayed text and from TTS input. It is kept as a timed media cue, preloaded by the client, and played when the text animation reaches that cue position.
|
||||
The server parses the tag into a structured `StoryTag`. The tag is never sent to the client as display text or TTS input.
|
||||
|
||||
Supported browser-friendly formats are recommended: `.ogg`, `.mp3`, and `.wav`. Keep files small enough for responsive preload.
|
||||
|
||||
@@ -17,6 +18,9 @@ Sound effect loudness is controlled by the master volume and sound effects volum
|
||||
|
||||
Document third-party source and license information here or next to the file.
|
||||
|
||||
Current test asset:
|
||||
Current assets:
|
||||
|
||||
- `squeaky-door.ogg`: Wikimedia Commons, "Squeaky door.ogg", sourced from PDSounds and marked public domain.
|
||||
- `steam-whistle.ogg`: Wikimedia Commons, "WWS SteamWhistle.ogg" by Work With Sounds / Konrad Gutkowski and Julian Blaschke, Creative Commons Attribution 4.0 International.
|
||||
- `horse-neigh.ogg`: Wikimedia Commons, "Wiehern.ogg" by Hue, released into the public domain by the author.
|
||||
- `church-bells.ogg`: Wikimedia Commons, "Churchbells.ogg" by Natalie, sourced from PDSounds and released into the public domain.
|
||||
|
||||