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
+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');