Add ink integration UI and media playback
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user