Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+148 -4
View File
@@ -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 */
+5 -4
View File
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

+3 -3
View File
@@ -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>
+144 -2
View File
@@ -89,6 +89,14 @@ class AudioManagerModule extends BaseModule {
this.handleMediaBlock(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail || {});
});
this.addEventListener(document, 'game:config', (event) => {
this.applyGameConfig(event.detail || {});
});
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key, value } = event.detail || {};
if (category !== 'audio') {
@@ -131,6 +139,15 @@ class AudioManagerModule extends BaseModule {
document.addEventListener('pointerdown', unlock, { passive: true });
document.addEventListener('keydown', unlock);
}
applyGameConfig(config) {
const assets = config?.assets || {};
this.assetRoots = {
images: assets.images || this.assetRoots.images,
music: assets.music || this.assetRoots.music,
sounds: assets.sounds || assets.sfx || this.assetRoots.sounds
};
}
/**
* Set up Web Audio API context if needed
@@ -438,7 +455,7 @@ class AudioManagerModule extends BaseModule {
}
if (cue.type === 'sfx') {
this.playSfx(cue.filename);
this.playSfx(cue.filename, cue);
} else if (cue.type === 'music') {
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
}
@@ -452,18 +469,122 @@ class AudioManagerModule extends BaseModule {
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
}
async playSfx(filename) {
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
const filename = String(tag?.value || tag?.filename || '').trim();
if (!key || !filename) {
return;
}
if (key === 'sfx' || key === 'sound' || key === 'audio') {
this.playSfx(filename, this.parseSfxTagOptions(tag.param || tag.options || ''));
return;
}
if (key === 'music') {
const options = this.parseMusicTagOptions(tag.param || tag.options || '');
this.playMusic(filename, options.mode, { loop: options.loop });
}
}
parseMusicTagOptions(optionText) {
const options = {
mode: 'crossfade',
loop: true
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['queue', 'crossfade', 'cut'].includes(token)) {
options.mode = token;
} else if (['loop', 'looped', 'repeat'].includes(token)) {
options.loop = true;
} else if (['once', 'single', 'no-loop', 'noloop'].includes(token)) {
options.loop = false;
} else if (key === 'loop') {
options.loop = !['false', '0', 'no', 'once'].includes(value);
} else if (key === 'mode' && ['queue', 'crossfade', 'cut'].includes(value)) {
options.mode = value;
}
});
return options;
}
parseSfxTagOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim().toLowerCase())
.filter(Boolean)
.forEach(token => {
const [key, value] = token.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(token)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(token)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(token)) {
options.maxDurationSeconds = Number(token.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
async playSfx(filename, options = {}) {
try {
const template = await this.preloadSfx(filename);
const audio = template.cloneNode(true);
audio.volume = this.getSfxVolume();
this.currentAudio = audio;
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000);
let maxTimer = null;
audio.addEventListener('ended', () => {
if (maxTimer) clearTimeout(maxTimer);
if (this.currentAudio === audio) {
this.currentAudio = null;
}
}, { once: true });
await audio.play();
if (maxDuration > 0) {
const timeoutDuration = endMode === 'fade'
? Math.max(0, maxDuration - fadeDuration)
: maxDuration;
maxTimer = setTimeout(() => {
if (audio.paused || audio.ended) return;
if (endMode === 'fade') {
console.log(`AudioManager: Fading sound effect ${filename} over ${fadeDuration}ms`);
this.fadeOutAudio(audio, fadeDuration);
} else {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
}
}, timeoutDuration);
}
console.log(`AudioManager: Playing sound effect ${filename}`);
return audio;
} catch (error) {
@@ -472,6 +593,27 @@ class AudioManagerModule extends BaseModule {
}
}
fadeOutAudio(audio, duration = 1000) {
if (!audio) return Promise.resolve(false);
const startVolume = audio.volume;
const startedAt = performance.now();
return new Promise(resolve => {
const step = () => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
audio.volume = startVolume * (1 - progress);
if (progress < 1 && !audio.paused && !audio.ended) {
requestAnimationFrame(step);
return;
}
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
resolve(true);
};
requestAnimationFrame(step);
});
}
async playMusic(filename, mode = 'crossfade', options = {}) {
const url = this.getAssetUrl('music', filename);
const shouldLoop = options.loop !== false;
+7 -1
View File
@@ -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';
}
+261
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
const choiceDisplay = new ChoiceDisplayModule();
export { choiceDisplay as ChoiceDisplay };
if (window.moduleRegistry) {
window.moduleRegistry.register(choiceDisplay);
}
window.ChoiceDisplay = choiceDisplay;
+103
View File
@@ -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;
+1 -27
View File
@@ -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
+12 -1
View File
@@ -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;
+4 -1
View File
@@ -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 },
+52 -28
View File
@@ -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];
}
+73 -90
View File
@@ -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: []
};
}
+1 -1
View File
@@ -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];
}
+41 -20
View File
@@ -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', {
+4 -3
View File
@@ -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;
+375 -40
View File
@@ -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' }
}));
+292 -36
View File
@@ -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]);
}
+38
View File
@@ -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
+1 -1
View File
@@ -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;
+2 -5
View File
@@ -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 }
}));
+78 -2
View File
@@ -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
+360 -30
View File
@@ -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');
+77 -5
View File
@@ -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 = `&gt; ${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
View File
@@ -1 +0,0 @@
{}
+40
View File
@@ -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
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
+40
View File
@@ -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"
}
+1 -7
View File
@@ -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:
+8 -4
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.