Fixed SD RPG
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
/** Escape for safe text nodes (not HTML injection). */
|
||||
function appendText(parent, text) {
|
||||
if (!text) return;
|
||||
parent.appendChild(document.createTextNode(text));
|
||||
}
|
||||
|
||||
const PLAYER_CHOSE_RE = /^\[Player chose:\s*(.+)\]$/s;
|
||||
const RP_TOKEN_RE = /("(?:[^"\\]|\\.)*"|«[^»]*»|'(?:[^'\\]|\\.)*'|\*\*[^*]+\*\*|\*[^*]+\*)/g;
|
||||
|
||||
const OUTCOME_CLASS = {
|
||||
'critical failure': 'outcome-crit-fail',
|
||||
failure: 'outcome-fail',
|
||||
success: 'outcome-success',
|
||||
'critical success': 'outcome-crit-success',
|
||||
};
|
||||
|
||||
export function isPlayerChoiceContent(text) {
|
||||
return PLAYER_CHOSE_RE.test((text || '').trim());
|
||||
}
|
||||
|
||||
export function parsePlayerChoice(text) {
|
||||
const m = (text || '').trim().match(PLAYER_CHOSE_RE);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function appendPlayerChoice(frag, label) {
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'rp-choice';
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'rp-choice-tag';
|
||||
tag.textContent = '🔘 Выбор';
|
||||
wrap.appendChild(tag);
|
||||
appendText(wrap, ' ');
|
||||
const lab = document.createElement('span');
|
||||
lab.className = 'rp-choice-label';
|
||||
lab.textContent = label;
|
||||
wrap.appendChild(lab);
|
||||
frag.appendChild(wrap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build DOM fragment for RP formatting (dialogue, action, player choice).
|
||||
*/
|
||||
export function buildRpFormatFragment(text) {
|
||||
const frag = document.createDocumentFragment();
|
||||
if (!text) return frag;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const choiceLabel = parsePlayerChoice(trimmed);
|
||||
if (choiceLabel !== null) {
|
||||
appendPlayerChoice(frag, choiceLabel);
|
||||
return frag;
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
for (const m of trimmed.matchAll(RP_TOKEN_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) appendText(frag, trimmed.slice(last, idx));
|
||||
|
||||
const token = m[0];
|
||||
if (
|
||||
(token.startsWith('"') && token.endsWith('"'))
|
||||
|| (token.startsWith('«') && token.endsWith('»'))
|
||||
|| (token.startsWith("'") && token.endsWith("'"))
|
||||
) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'rp-dialogue';
|
||||
span.textContent = token;
|
||||
frag.appendChild(span);
|
||||
} else if (token.startsWith('**') && token.endsWith('**')) {
|
||||
const em = document.createElement('em');
|
||||
em.className = 'rp-action';
|
||||
em.textContent = token.slice(2, -2);
|
||||
frag.appendChild(em);
|
||||
} else if (token.startsWith('*') && token.endsWith('*')) {
|
||||
const em = document.createElement('em');
|
||||
em.className = 'rp-action';
|
||||
em.textContent = token.slice(1, -1);
|
||||
frag.appendChild(em);
|
||||
} else {
|
||||
appendText(frag, token);
|
||||
}
|
||||
last = idx + token.length;
|
||||
}
|
||||
if (last < trimmed.length) appendText(frag, trimmed.slice(last));
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dice override block on user bubble (intent struck through → resolution).
|
||||
*/
|
||||
export function buildDiceOverrideFragment(resolution) {
|
||||
const { intent_text, resolution_text, roll, outcome } = resolution;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'dice-user-override';
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = `dice-user-badge ${OUTCOME_CLASS[outcome] || ''}`;
|
||||
badge.textContent = `🎲 ${roll} · ${outcome || 'roll'}`;
|
||||
wrap.appendChild(badge);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dice-user-row';
|
||||
|
||||
const struck = document.createElement('span');
|
||||
struck.className = 'dice-intent-struck';
|
||||
struck.textContent = intent_text || '';
|
||||
row.appendChild(struck);
|
||||
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'dice-intent-arrow';
|
||||
arrow.textContent = '→';
|
||||
row.appendChild(arrow);
|
||||
|
||||
const resolved = document.createElement('span');
|
||||
resolved.className = 'dice-intent-resolved';
|
||||
resolved.textContent = resolution_text || '';
|
||||
row.appendChild(resolved);
|
||||
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function applyDiceOverrideToBubble(bubble, resolution) {
|
||||
if (!resolution?.resolution_text) return;
|
||||
bubble.dataset.diceOverride = '1';
|
||||
const raw = bubble.dataset.raw ?? '';
|
||||
const frag = document.createDocumentFragment();
|
||||
if (raw && !isPlayerChoiceContent(raw)) {
|
||||
frag.appendChild(buildRpFormatFragment(raw));
|
||||
frag.appendChild(document.createElement('br'));
|
||||
} else if (raw) {
|
||||
frag.appendChild(buildRpFormatFragment(raw));
|
||||
frag.appendChild(document.createElement('br'));
|
||||
}
|
||||
frag.appendChild(buildDiceOverrideFragment(resolution));
|
||||
bubble.innerHTML = '';
|
||||
bubble.appendChild(frag);
|
||||
bubble.classList.add('rp-formatted', 'has-dice-override');
|
||||
}
|
||||
|
||||
export function applyRpFormatToBubble(bubble) {
|
||||
if (bubble.dataset.diceOverride === '1' && bubble._diceResolution) {
|
||||
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
|
||||
return;
|
||||
}
|
||||
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
|
||||
bubble.dataset.raw = raw;
|
||||
bubble.innerHTML = '';
|
||||
bubble.appendChild(buildRpFormatFragment(raw));
|
||||
bubble.classList.add('rp-formatted');
|
||||
}
|
||||
|
||||
export function applyRpPlainToBubble(bubble) {
|
||||
const raw = bubble.dataset.raw ?? bubble.textContent ?? '';
|
||||
bubble.dataset.raw = raw;
|
||||
bubble.textContent = raw;
|
||||
bubble.classList.remove('rp-formatted');
|
||||
}
|
||||
|
||||
export function attachFormatToggle(wrapper, bubble) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'format-btn';
|
||||
btn.textContent = '↩ Текст';
|
||||
let showingPlain = false;
|
||||
|
||||
const syncBtn = () => {
|
||||
btn.textContent = showingPlain ? '✨' : '↩ Текст';
|
||||
btn.title = showingPlain ? 'Форматировать' : 'Показать оригинал';
|
||||
};
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (showingPlain) {
|
||||
if (bubble._diceResolution) {
|
||||
applyDiceOverrideToBubble(bubble, bubble._diceResolution);
|
||||
} else {
|
||||
applyRpFormatToBubble(bubble);
|
||||
}
|
||||
showingPlain = false;
|
||||
} else {
|
||||
applyRpPlainToBubble(bubble);
|
||||
showingPlain = true;
|
||||
}
|
||||
syncBtn();
|
||||
});
|
||||
|
||||
syncBtn();
|
||||
wrapper.appendChild(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
export function initBubbleContent(bubble, rawText, { formatted = true, actionResolution = null } = {}) {
|
||||
bubble.dataset.raw = rawText ?? '';
|
||||
bubble._diceResolution = actionResolution || null;
|
||||
if (actionResolution?.resolution_text && formatted) {
|
||||
bubble.dataset.diceOverride = '1';
|
||||
applyDiceOverrideToBubble(bubble, actionResolution);
|
||||
return;
|
||||
}
|
||||
if (formatted && rawText) {
|
||||
applyRpFormatToBubble(bubble);
|
||||
} else {
|
||||
bubble.textContent = rawText ?? '';
|
||||
bubble.classList.remove('rp-formatted', 'has-dice-override');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user