Checkpoint Eibenreith ink architecture
This commit is contained in:
@@ -517,17 +517,20 @@ export class BrowserTTSModule extends TTSHandlerModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak preloaded speech
|
||||
* Not applicable for the browser TTS (always returns false)
|
||||
* @param {Object} preloadData - Preloaded speech data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status (always false)
|
||||
* Speak preloaded speech metadata.
|
||||
* Browser TTS cannot preload audio bytes, so its preload result carries the
|
||||
* text and duration estimate and playback still happens through speak().
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: false, reason: 'not_supported' });
|
||||
const text = String(preloadData?.text || '').trim();
|
||||
if (!text) {
|
||||
if (typeof callback === 'function') {
|
||||
callback({ success: false, reason: 'empty_preloaded_text' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
return this.speak(text, callback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
this.choices = [];
|
||||
this.inputMode = 'none';
|
||||
this.processState = document.documentElement.dataset.processState || 'loading';
|
||||
this.currentTurnId = 0;
|
||||
this.autoTurnCounter = 0;
|
||||
this.lastAutoTurn = new Map();
|
||||
this.template = {
|
||||
cells: {
|
||||
default: {
|
||||
@@ -39,8 +42,15 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
'shuffleChoices',
|
||||
'randomInt',
|
||||
'assignLetters',
|
||||
'selectAutoChoice',
|
||||
'isAutoChoiceReady',
|
||||
'getAutoChoiceConfig',
|
||||
'getAutoChoiceKey',
|
||||
'getAutoChoiceDelay',
|
||||
'markAutoChoiceTriggered',
|
||||
'selectChoice',
|
||||
'getTagValue',
|
||||
'getTag',
|
||||
'getTemplateCell',
|
||||
'renderChoiceText'
|
||||
]);
|
||||
@@ -60,10 +70,23 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
this.addEventListener(document, 'story:process-state', (event) => {
|
||||
this.handleProcessState(event.detail?.state || 'ready');
|
||||
});
|
||||
this.addEventListener(document, 'story:turn-start', () => {
|
||||
this.addEventListener(document, 'story:turn-start', (event) => {
|
||||
const turnId = Number(event.detail?.turnId);
|
||||
if (turnId === 1) {
|
||||
this.currentTurnId = 0;
|
||||
this.autoTurnCounter = 0;
|
||||
this.lastAutoTurn.clear();
|
||||
}
|
||||
this.processState = 'waiting-generating';
|
||||
this.render();
|
||||
});
|
||||
this.addEventListener(document, 'story:turn-complete', (event) => {
|
||||
const turnId = Number(event.detail?.turnId);
|
||||
if (Number.isInteger(turnId) && turnId !== this.currentTurnId) {
|
||||
this.currentTurnId = turnId;
|
||||
this.autoTurnCounter += 1;
|
||||
}
|
||||
});
|
||||
this.addEventListener(document, 'story:history-restoring', (event) => {
|
||||
document.documentElement.dataset.historyRestoring = event.detail?.active ? 'true' : 'false';
|
||||
this.render();
|
||||
@@ -130,7 +153,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
}
|
||||
|
||||
const letter = event.key.toUpperCase();
|
||||
const choice = this.choices.find((item) => item.letter === letter);
|
||||
const choice = this.choices.find((item) => !item.auto && item.letter === letter);
|
||||
if (!choice) {
|
||||
return;
|
||||
}
|
||||
@@ -151,11 +174,20 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
sourceOrder: order,
|
||||
optional: this.hasTag(tags, 'optional'),
|
||||
letter: '',
|
||||
templateCell: this.getTemplateCell({ ...choice, tags, category })
|
||||
templateCell: this.getTemplateCell({ ...choice, tags, category }),
|
||||
auto: this.hasTag(tags, 'auto')
|
||||
};
|
||||
});
|
||||
|
||||
return this.assignLetters(this.orderChoicesForPresentation(normalized));
|
||||
const autoChoices = normalized
|
||||
.filter((choice) => choice.auto)
|
||||
.sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const visibleChoices = normalized.filter((choice) => !choice.auto);
|
||||
|
||||
return [
|
||||
...autoChoices,
|
||||
...this.assignLetters(this.orderChoicesForPresentation(visibleChoices))
|
||||
];
|
||||
}
|
||||
|
||||
orderChoicesForPresentation(choices) {
|
||||
@@ -254,11 +286,17 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
}
|
||||
|
||||
getTagValue(tags, key) {
|
||||
const normalizedKey = String(key).toLowerCase();
|
||||
const tag = tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey);
|
||||
const tag = this.getTag(tags, key);
|
||||
return tag?.value;
|
||||
}
|
||||
|
||||
getTag(tags, key) {
|
||||
const normalizedKey = String(key).toLowerCase();
|
||||
return Array.isArray(tags)
|
||||
? tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
hasTag(tags, key) {
|
||||
const normalizedKey = String(key).toLowerCase();
|
||||
return Array.isArray(tags) && tags.some((item) => String(item?.key || '').toLowerCase() === normalizedKey);
|
||||
@@ -276,8 +314,15 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
this.inputMode === 'choice' &&
|
||||
this.choices.length > 0 &&
|
||||
this.processState === 'ready';
|
||||
this.container.hidden = !readyForChoices;
|
||||
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
|
||||
|
||||
if (readyForChoices && this.selectAutoChoice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleChoices = this.choices.filter((choice) => !choice.auto);
|
||||
const hasVisibleChoices = visibleChoices.length > 0;
|
||||
this.container.hidden = !readyForChoices || !hasVisibleChoices;
|
||||
this.container.dataset.choiceReady = readyForChoices && hasVisibleChoices ? 'true' : 'false';
|
||||
if (this.container.hidden) {
|
||||
return;
|
||||
}
|
||||
@@ -285,7 +330,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
const list = document.createElement('ol');
|
||||
list.className = 'choice-list choice-template-default';
|
||||
|
||||
this.choices.forEach((choice) => {
|
||||
visibleChoices.forEach((choice) => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'choice-list-item';
|
||||
item.classList.toggle('choice-optional', Boolean(choice.optional));
|
||||
@@ -307,6 +352,75 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
this.container.appendChild(list);
|
||||
}
|
||||
|
||||
selectAutoChoice() {
|
||||
const autoChoice = this.choices.find((choice) => choice.auto && this.isAutoChoiceReady(choice));
|
||||
if (!autoChoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.markAutoChoiceTriggered(autoChoice);
|
||||
this.selectChoice(autoChoice.index);
|
||||
return true;
|
||||
}
|
||||
|
||||
isAutoChoiceReady(choice) {
|
||||
const config = this.getAutoChoiceConfig(choice);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
if (config.delay <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastTurn = this.lastAutoTurn.get(config.key);
|
||||
if (!Number.isFinite(lastTurn)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (this.autoTurnCounter - lastTurn) >= config.delay;
|
||||
}
|
||||
|
||||
getAutoChoiceConfig(choice) {
|
||||
const autoTag = this.getTag(choice.tags, 'auto');
|
||||
if (!autoTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: this.getAutoChoiceKey(autoTag),
|
||||
delay: this.getAutoChoiceDelay(autoTag)
|
||||
};
|
||||
}
|
||||
|
||||
getAutoChoiceKey(autoTag) {
|
||||
const value = String(autoTag?.value || '').trim();
|
||||
return value || '__global';
|
||||
}
|
||||
|
||||
getAutoChoiceDelay(autoTag) {
|
||||
const candidates = [autoTag?.param, autoTag?.value];
|
||||
for (const candidate of candidates) {
|
||||
const text = String(candidate || '').trim();
|
||||
if (!text) continue;
|
||||
|
||||
const namedMatch = text.match(/(?:turns?|delay|cooldown)\s*=\s*(\d+)/i);
|
||||
const plainMatch = text.match(/^\d+$/);
|
||||
const value = namedMatch ? Number(namedMatch[1]) : plainMatch ? Number(text) : NaN;
|
||||
if (Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
markAutoChoiceTriggered(choice) {
|
||||
const config = this.getAutoChoiceConfig(choice);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
this.lastAutoTurn.set(config.key, this.autoTurnCounter);
|
||||
}
|
||||
|
||||
async selectChoice(index) {
|
||||
if (!this.socketClient) {
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
|
||||
@@ -33,6 +33,8 @@ class GameLoopModule extends BaseModule {
|
||||
this.clientResetGeneration = 0;
|
||||
this.restoreGeneration = 0;
|
||||
this.pendingHistoryRestoreCleanup = null;
|
||||
this.gameOperationGeneration = 0;
|
||||
this.autoSaveGeneration = 0;
|
||||
|
||||
// Bind methods using parent's bindMethods utility
|
||||
this.bindMethods([
|
||||
@@ -212,6 +214,8 @@ class GameLoopModule extends BaseModule {
|
||||
async resumeAutosaveIfAvailable() {
|
||||
if (this.resumeAttempted) return false;
|
||||
this.resumeAttempted = true;
|
||||
const operationGeneration = ++this.gameOperationGeneration;
|
||||
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
|
||||
|
||||
const storyHistory = this.getModule('story-history');
|
||||
const socketClient = this.getModule('socket-client');
|
||||
@@ -220,11 +224,13 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
|
||||
const browserSave = await storyHistory.loadSlot(this.autoSaveSlot);
|
||||
if (!isCurrentOperation()) return false;
|
||||
if (!browserSave?.inkState || browserSave.running === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
if (!isCurrentOperation()) return false;
|
||||
this.currentChoices = [];
|
||||
this.currentInputMode = 'none';
|
||||
document.dispatchEvent(new CustomEvent('story:choices', { detail: [] }));
|
||||
@@ -234,6 +240,7 @@ class GameLoopModule extends BaseModule {
|
||||
}));
|
||||
|
||||
const response = await socketClient.resumeGame(browserSave.inkState);
|
||||
if (!isCurrentOperation()) return false;
|
||||
if (!response?.success) {
|
||||
console.warn('GameLoop: autosave resume failed', response);
|
||||
document.dispatchEvent(new CustomEvent('story:history-restoring', {
|
||||
@@ -249,6 +256,7 @@ class GameLoopModule extends BaseModule {
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: false });
|
||||
if (!isCurrentOperation()) return false;
|
||||
this.restoreInputStateFromSave(browserSave, 'autosave-resume');
|
||||
return true;
|
||||
}
|
||||
@@ -297,6 +305,8 @@ class GameLoopModule extends BaseModule {
|
||||
async requestStartGame() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
const operationGeneration = ++this.gameOperationGeneration;
|
||||
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
|
||||
|
||||
this.gameState.started = true;
|
||||
this.gameState.startedOnce = true;
|
||||
@@ -304,9 +314,11 @@ class GameLoopModule extends BaseModule {
|
||||
this.gameState.canSave = true;
|
||||
this.updateUIState();
|
||||
await this.resetClientPlaybackAndDisplay();
|
||||
if (!isCurrentOperation()) return;
|
||||
const storyHistory = this.getModule('story-history');
|
||||
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
|
||||
await storyHistory.startNewGame();
|
||||
if (!isCurrentOperation()) return;
|
||||
if (typeof storyHistory.saveSlot === 'function') {
|
||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
||||
inkState: null,
|
||||
@@ -317,6 +329,7 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
const response = await socketClient.newGame();
|
||||
if (!isCurrentOperation()) return;
|
||||
if (!response?.success) {
|
||||
console.error('GameLoop: newGame failed', response);
|
||||
this.gameState.started = false;
|
||||
@@ -331,6 +344,7 @@ class GameLoopModule extends BaseModule {
|
||||
this.gameState.canLoad = Boolean(response.canLoad);
|
||||
this.updateUIState();
|
||||
if (response.savedState && storyHistory && typeof storyHistory.saveSlot === 'function') {
|
||||
if (!isCurrentOperation()) return;
|
||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
||||
inkState: response.savedState,
|
||||
choices: [],
|
||||
@@ -375,12 +389,16 @@ class GameLoopModule extends BaseModule {
|
||||
async requestLoadGame() {
|
||||
const socketClient = this.getModule('socket-client');
|
||||
if (!socketClient) return;
|
||||
const operationGeneration = ++this.gameOperationGeneration;
|
||||
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
|
||||
|
||||
const storyHistory = this.getModule('story-history');
|
||||
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
|
||||
? await storyHistory.loadSlot(1)
|
||||
: null;
|
||||
if (!isCurrentOperation()) return;
|
||||
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
|
||||
if (!isCurrentOperation()) return;
|
||||
if (!hasSave?.result) {
|
||||
this.gameState.canLoad = false;
|
||||
this.updateUIState();
|
||||
@@ -394,7 +412,9 @@ class GameLoopModule extends BaseModule {
|
||||
this.gameState.canLoad = true;
|
||||
this.updateUIState();
|
||||
await this.restoreBrowserSave(browserSave, 'load-game', { resetDisplay: true });
|
||||
if (!isCurrentOperation()) return;
|
||||
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
|
||||
if (!isCurrentOperation()) return;
|
||||
if (response?.success && browserSave && Array.isArray(browserSave.choices)) {
|
||||
this.currentChoices = browserSave.choices;
|
||||
this.currentInputMode = browserSave.inputMode || this.currentInputMode;
|
||||
@@ -523,11 +543,16 @@ class GameLoopModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSaveGeneration = this.autoSaveGeneration;
|
||||
const gameId = storyHistory.currentGameId || null;
|
||||
this.autoSaveInProgress = true;
|
||||
try {
|
||||
const response = this.gameState.started && typeof socketClient.exportGameState === 'function'
|
||||
? await socketClient.exportGameState()
|
||||
: null;
|
||||
if (autoSaveGeneration !== this.autoSaveGeneration || storyHistory.currentGameId !== gameId) {
|
||||
return;
|
||||
}
|
||||
if (!response?.success || !response.savedState) {
|
||||
return;
|
||||
}
|
||||
@@ -535,6 +560,7 @@ class GameLoopModule extends BaseModule {
|
||||
|
||||
const audioManager = this.getModule('audio-manager');
|
||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
||||
gameId,
|
||||
inkState: response.savedState,
|
||||
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
|
||||
renderedLineCount: storyHistory.renderedLineCount || 0,
|
||||
@@ -545,9 +571,11 @@ class GameLoopModule extends BaseModule {
|
||||
});
|
||||
} finally {
|
||||
this.autoSaveInProgress = false;
|
||||
if (this.autoSaveQueued) {
|
||||
if (autoSaveGeneration === this.autoSaveGeneration && this.autoSaveQueued) {
|
||||
this.autoSaveQueued = false;
|
||||
this.autoSaveCurrentSession();
|
||||
} else {
|
||||
this.autoSaveQueued = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +596,7 @@ class GameLoopModule extends BaseModule {
|
||||
async resetClientPlaybackAndDisplay() {
|
||||
this.clientResetGeneration += 1;
|
||||
this.restoreGeneration += 1;
|
||||
this.autoSaveGeneration += 1;
|
||||
this.clearPendingHistoryRestore('client-reset');
|
||||
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
|
||||
@@ -30,6 +30,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS;
|
||||
this.generationRequests = new Map();
|
||||
this.assetPreloadRequests = new Map();
|
||||
this.queueGeneration = 0;
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
@@ -64,6 +65,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
'calculateAnimationTiming',
|
||||
'isCurrentQueueItem',
|
||||
'clear'
|
||||
]);
|
||||
}
|
||||
@@ -170,12 +172,14 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
this.isProcessing = true;
|
||||
const item = this.sentenceQueue[0];
|
||||
const queueGeneration = this.queueGeneration;
|
||||
|
||||
try {
|
||||
if (this.pauseBeforeNextReason) {
|
||||
const reason = this.pauseBeforeNextReason;
|
||||
this.pauseBeforeNextReason = null;
|
||||
await this.waitForManualContinue(reason);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
@@ -189,10 +193,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
}));
|
||||
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
// generation for the next spoken paragraph.
|
||||
this.prefetchAhead();
|
||||
this.prefetchAhead(4, queueGeneration);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Notify display handler with complete sentence
|
||||
if (this.onSentenceReadyCallback) {
|
||||
@@ -201,22 +207,28 @@ class SentenceQueueModule extends BaseModule {
|
||||
sentence.playbackStartedAt = performance.now();
|
||||
this.onSentenceReadyCallback(sentence, resolve);
|
||||
});
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
}
|
||||
|
||||
const mediaPauseSeconds = this.getMediaPauseSeconds(sentence);
|
||||
if (mediaPauseSeconds > 0) {
|
||||
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
}
|
||||
|
||||
if (this.shouldPauseAfterSentence(sentence)) {
|
||||
await this.waitForManualContinue(sentence.id);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
}
|
||||
|
||||
// Remove from queue and continue
|
||||
this.sentenceQueue.shift();
|
||||
if (this.sentenceQueue[0] === item) {
|
||||
this.sentenceQueue.shift();
|
||||
}
|
||||
if (item.callback) item.callback({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
console.error("SentenceQueue: Error processing sentence:", error);
|
||||
const failedItem = this.sentenceQueue.shift();
|
||||
console.warn('SentenceQueue: Dropped failed queue item so playback can continue', {
|
||||
@@ -843,7 +855,11 @@ class SentenceQueueModule extends BaseModule {
|
||||
return this.prepareSentence(item);
|
||||
}
|
||||
|
||||
prefetchAhead(maxLookahead = 4) {
|
||||
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
|
||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||
}
|
||||
|
||||
prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) {
|
||||
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 }
|
||||
@@ -871,11 +887,13 @@ class SentenceQueueModule extends BaseModule {
|
||||
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
|
||||
|
||||
const promise = (async () => {
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
await this.preloadAssetsForItem(nextItem, {
|
||||
sentenceId: nextItem.id,
|
||||
blocking: false,
|
||||
prefetch: true
|
||||
});
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
|
||||
if (!this.isSpeechItem(nextItem)) {
|
||||
return null;
|
||||
@@ -892,6 +910,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
});
|
||||
})()
|
||||
.then(() => {
|
||||
if (queueGeneration !== this.queueGeneration) return false;
|
||||
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
|
||||
@@ -1316,10 +1335,13 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queueGeneration += 1;
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.cancelGenerationRequests('sentence-queue-cleared');
|
||||
this.cancelAssetPreloads('sentence-queue-cleared');
|
||||
this.prefetchingSpeech.clear();
|
||||
this.pauseBeforeNextReason = null;
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-cleared' }
|
||||
}));
|
||||
|
||||
@@ -798,7 +798,9 @@ class SocketClientModule extends BaseModule {
|
||||
|
||||
try {
|
||||
this.clearPendingCommand('new-command');
|
||||
this.socket.emit('playerCommand', { command });
|
||||
const requestId = ++this.gameApiRequestId;
|
||||
this.latestNarrativeRequestId = requestId;
|
||||
this.socket.emit('playerCommand', { command, requestId });
|
||||
this.pendingCommand = command;
|
||||
this.pendingCommandTimer = setTimeout(() => {
|
||||
const timedOutCommand = this.pendingCommand;
|
||||
|
||||
@@ -813,7 +813,7 @@ class TTSFactoryModule extends BaseModule {
|
||||
if (typeof handler.preloadSpeech === 'function') {
|
||||
console.log(`TTS Factory: Generating and caching speech for hash ${hash}`);
|
||||
const preloadData = await handler.preloadSpeech(text, options);
|
||||
if (preloadData && preloadData.success) {
|
||||
if (preloadData && preloadData.success && preloadData.audioData) {
|
||||
// Cache the speech
|
||||
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.scrollTargetLine = null;
|
||||
this.scrollRequestId = 0;
|
||||
this.renderWindowToken = 0;
|
||||
this.displayGeneration = 0;
|
||||
this.wheelLineAccumulator = 0;
|
||||
this.viewportLineCount = 1;
|
||||
this.lineHeightPx = 24;
|
||||
@@ -124,6 +125,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'rerenderStory',
|
||||
'clear',
|
||||
'scheduleRerender',
|
||||
'isDisplayGenerationCurrent',
|
||||
'measureText',
|
||||
'loadCSS',
|
||||
'showChoices',
|
||||
@@ -955,15 +957,35 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generation = this.displayGeneration;
|
||||
const sentenceGameId = sentence.gameId || null;
|
||||
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
|
||||
|
||||
try {
|
||||
await this.ensureLiveTailWindow();
|
||||
if (!isCurrent()) return null;
|
||||
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
|
||||
if (!isCurrent()) return null;
|
||||
this.rebuildLayoutExclusions(this.renderedItems);
|
||||
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
|
||||
const element = await this.renderStoryBlock(sentence, { animate: true, playback: true, placement: 'append' });
|
||||
const element = await this.renderStoryBlock(sentence, {
|
||||
animate: true,
|
||||
playback: true,
|
||||
placement: 'append',
|
||||
token: this.renderWindowToken,
|
||||
generation
|
||||
});
|
||||
if (!element) return null;
|
||||
if (!isCurrent()) {
|
||||
element.remove();
|
||||
return null;
|
||||
}
|
||||
sentence.element = element;
|
||||
await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' });
|
||||
if (!isCurrent()) {
|
||||
element.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sentence.kind === 'image') {
|
||||
this.revealImageBlock(element);
|
||||
@@ -972,6 +994,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
} else if (sentence.kind === 'music') {
|
||||
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
||||
}
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
this.dispatchDeferredTagsForBlock(sentence);
|
||||
|
||||
@@ -990,34 +1013,51 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
async rerenderStory() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||
const generation = this.displayGeneration;
|
||||
const activeLine = this.getCurrentScrollLine();
|
||||
await this.renderHistoryWindow([...this.renderedItems]);
|
||||
await this.renderHistoryWindow([...this.renderedItems], { generation });
|
||||
if (!this.isDisplayGenerationCurrent(generation)) return;
|
||||
await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false });
|
||||
}
|
||||
|
||||
isDisplayGenerationCurrent(generation = this.displayGeneration, gameId = null) {
|
||||
if (generation !== this.displayGeneration) return false;
|
||||
if (gameId && this.storyHistory?.currentGameId && this.storyHistory.currentGameId !== gameId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async restoreFromHistory(saveRecord = {}) {
|
||||
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
|
||||
const generation = this.displayGeneration;
|
||||
const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0));
|
||||
if (!this.storyHistory.renderedLineCount) {
|
||||
await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId);
|
||||
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
|
||||
}
|
||||
const targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0);
|
||||
if (latestRenderedBlockId > 0) {
|
||||
const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId);
|
||||
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
|
||||
const targetBlockId = Math.max(1, Number(targetBlock?.blockId || latestRenderedBlockId));
|
||||
await this.renderWindowForBounds({
|
||||
start: Math.max(1, targetBlockId - this.historyBufferBlocks),
|
||||
end: Math.min(latestRenderedBlockId, targetBlockId + this.historyBufferBlocks),
|
||||
targetBlockId,
|
||||
windowOriginLine: this.getTopLineForActiveLine(targetLine)
|
||||
windowOriginLine: this.getTopLineForActiveLine(targetLine),
|
||||
gameId: saveRecord.gameId,
|
||||
generation
|
||||
});
|
||||
} else {
|
||||
await this.renderHistoryWindow([], { windowOriginLine: 0 });
|
||||
await this.renderHistoryWindow([], { windowOriginLine: 0, generation });
|
||||
}
|
||||
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
|
||||
await this.scrollTo(targetLine, {
|
||||
mode: 'restore-bottom',
|
||||
smooth: false
|
||||
});
|
||||
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
|
||||
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || latestRenderedBlockId || 1 });
|
||||
}
|
||||
|
||||
@@ -1038,10 +1078,12 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
targetContainer = this.paragraphContainer,
|
||||
renderedItemsTarget = this.renderedItems,
|
||||
token = null,
|
||||
recordMetrics = true
|
||||
recordMetrics = true,
|
||||
generation = this.displayGeneration
|
||||
} = options;
|
||||
if (!item || !this.paragraphContainer) return null;
|
||||
const renderable = await this.prepareRenderableBlock(item);
|
||||
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return null;
|
||||
if (token != null && token !== this.renderWindowToken) return null;
|
||||
if (!renderable) return null;
|
||||
|
||||
@@ -1110,6 +1152,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
if (recordMetrics && item.blockId != null) {
|
||||
const updated = await this.recordRenderedMetrics(item.blockId, element, renderable.lineCount, renderable.lineStart);
|
||||
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) {
|
||||
element?.remove();
|
||||
return null;
|
||||
}
|
||||
if (token != null && token !== this.renderWindowToken) {
|
||||
element?.remove();
|
||||
return null;
|
||||
@@ -1130,6 +1176,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
async renderHistoryWindow(blocks = [], options = {}) {
|
||||
if (!this.paragraphContainer) return;
|
||||
const generation = options.generation ?? this.displayGeneration;
|
||||
if (!this.isDisplayGenerationCurrent(generation, options.gameId || blocks[0]?.gameId || null)) return;
|
||||
const token = ++this.renderWindowToken;
|
||||
const orderedBlocks = Array.isArray(blocks)
|
||||
? [...blocks].sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
|
||||
@@ -1152,12 +1200,15 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
placement: 'append',
|
||||
targetContainer: fragment,
|
||||
renderedItemsTarget: nextRenderedItems,
|
||||
token
|
||||
token,
|
||||
generation
|
||||
});
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, item.gameId || options.gameId || null)) return;
|
||||
}
|
||||
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, options.gameId || orderedBlocks[0]?.gameId || null)) return;
|
||||
this.paragraphContainer.replaceChildren(fragment);
|
||||
this.renderedItems = nextRenderedItems;
|
||||
this.historyWindowStartId = orderedBlocks[0]?.blockId || 1;
|
||||
@@ -1263,9 +1314,11 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) {
|
||||
if (!this.layoutExclusions.length || !this.paragraphContainer) return;
|
||||
const generation = this.displayGeneration;
|
||||
const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item));
|
||||
for (const item of candidates) {
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
|
||||
const oldElement = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
|
||||
if (!oldElement) continue;
|
||||
const previousFlowLine = this.layoutFlowLine;
|
||||
@@ -1274,6 +1327,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.layoutFlowLine = previousFlowLine;
|
||||
this.layoutExclusions = previousExclusions;
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
|
||||
renderable.layout.lineCount = Math.max(1, Number(item.lineCount ?? item.metadata?.lineCount ?? renderable.lineCount));
|
||||
renderable.lineCount = renderable.layout.lineCount;
|
||||
const replacement = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
|
||||
@@ -1294,6 +1348,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
async renderIncrementalWindow(bounds = {}, requestId = null) {
|
||||
if (!this.storyHistory || !this.paragraphContainer) return;
|
||||
const generation = bounds.generation ?? this.displayGeneration;
|
||||
const gameId = bounds.gameId || this.storyHistory.currentGameId;
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
if (!this.renderedItems.length) {
|
||||
await this.renderWindowForBounds(bounds, requestId);
|
||||
return;
|
||||
@@ -1310,15 +1367,17 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const missingBeforeEnd = Math.min(end, (this.historyWindowStartId || start) - 1);
|
||||
const missingAfterStart = Math.max(start, (this.historyWindowEndId || 0) + 1);
|
||||
const missingBeforeBlocks = start <= missingBeforeEnd
|
||||
? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, missingBeforeEnd)
|
||||
? await this.storyHistory.getBlocksRange(gameId, start, missingBeforeEnd)
|
||||
: [];
|
||||
if (requestId != null && requestId !== this.scrollRequestId) return;
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
const missingAfterBlocks = missingAfterStart <= end
|
||||
? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, missingAfterStart, end)
|
||||
? await this.storyHistory.getBlocksRange(gameId, missingAfterStart, end)
|
||||
: [];
|
||||
if (requestId != null && requestId !== this.scrollRequestId) return;
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
|
||||
const uniqueBeforeBlocks = missingBeforeBlocks.filter(block => {
|
||||
const id = Number(block?.blockId || 0);
|
||||
@@ -1355,6 +1414,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
targetContainer: beforeFragment,
|
||||
renderedItemsTarget: beforeItems,
|
||||
token,
|
||||
generation,
|
||||
recordMetrics: false
|
||||
});
|
||||
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
|
||||
@@ -1378,6 +1438,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
targetContainer: afterFragment,
|
||||
renderedItemsTarget: afterItems,
|
||||
token,
|
||||
generation,
|
||||
recordMetrics: false
|
||||
});
|
||||
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
|
||||
@@ -1402,6 +1463,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.rebuildLayoutExclusions(this.renderedItems);
|
||||
await this.reflowTextBlocksForActiveExclusions(token);
|
||||
if (token !== this.renderWindowToken) return;
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
this.setVirtualPadding();
|
||||
this.updateStoryScrollbar();
|
||||
}
|
||||
@@ -2033,6 +2095,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
async ensureLiveTailWindow() {
|
||||
if (!this.storyHistory || !this.paragraphContainer) return;
|
||||
const generation = this.displayGeneration;
|
||||
const gameId = this.storyHistory.currentGameId;
|
||||
const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
|
||||
if (latestRendered > 0) {
|
||||
const start = Math.max(1, latestRendered - (this.visibleBlockLimit - 2));
|
||||
@@ -2043,8 +2107,11 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
start,
|
||||
end,
|
||||
targetBlockId: latestRendered,
|
||||
windowOriginLine: this.getTopLineForActiveLine(liveEndLine)
|
||||
windowOriginLine: this.getTopLineForActiveLine(liveEndLine),
|
||||
gameId,
|
||||
generation
|
||||
});
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
}
|
||||
} else if (this.renderedItems.length) {
|
||||
this.paragraphContainer.innerHTML = '';
|
||||
@@ -2104,11 +2171,20 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
async renderWindowForBounds(bounds = {}, requestId = null) {
|
||||
if (!this.storyHistory) return;
|
||||
const generation = bounds.generation ?? this.displayGeneration;
|
||||
const gameId = bounds.gameId || this.storyHistory.currentGameId;
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
const start = Math.max(1, Number(bounds.start || 1));
|
||||
const end = Math.max(start, Number(bounds.end || start));
|
||||
const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end);
|
||||
const blocks = await this.storyHistory.getBlocksRange(gameId, start, end);
|
||||
if (requestId != null && requestId !== this.scrollRequestId) return;
|
||||
await this.renderHistoryWindow(blocks, { windowOriginLine: bounds.windowOriginLine });
|
||||
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
|
||||
await this.renderHistoryWindow(blocks, {
|
||||
windowOriginLine: bounds.windowOriginLine,
|
||||
gameId,
|
||||
generation
|
||||
});
|
||||
}
|
||||
|
||||
focusTurn(turnId) {
|
||||
@@ -2335,6 +2411,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.displayGeneration += 1;
|
||||
this.renderWindowToken += 1;
|
||||
this.scrollRequestId += 1;
|
||||
if (this.scrollAnimationFrameId != null) {
|
||||
@@ -2370,6 +2447,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.historyWindowStartId = 1;
|
||||
this.historyWindowEndId = 0;
|
||||
this.storyTopLine = 0;
|
||||
this.scrollTargetLine = null;
|
||||
this.wheelLineAccumulator = 0;
|
||||
this.draggingStoryScrollbar = false;
|
||||
this.scrollbarPreviewLine = null;
|
||||
this.activeCenterBlockId = null;
|
||||
this.layoutFlowLine = 0;
|
||||
this.layoutExclusions = [];
|
||||
|
||||
Reference in New Issue
Block a user