Fixed SD RPG

This commit is contained in:
2026-06-04 08:05:06 +03:00
parent d4cd8f02f4
commit 6189a5fb74
62 changed files with 6969 additions and 552 deletions
+209
View File
@@ -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');
}
}