Add ink integration UI and media playback

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