Add ink integration UI and media playback
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
const choiceDisplay = new ChoiceDisplayModule();
|
||||
|
||||
export { choiceDisplay as ChoiceDisplay };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(choiceDisplay);
|
||||
}
|
||||
|
||||
window.ChoiceDisplay = choiceDisplay;
|
||||
Reference in New Issue
Block a user