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
+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]);
}