Aswini-Kumar's picture
Initial commit — Omniscient Reader Scenario Simulator
7d99fde
Raw
History Blame Contribute Delete
35.9 kB
/* ═══════════════════════════════════════════════════════════════════
ORV SCENARIO SIMULATOR — GAME.JS
Interactive effects, MutationObserver, event delegation
Vanilla JS · Self-contained IIFE · Gradio-compatible
═══════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
// ── Constants ────────────────────────────────────────────────────
const TYPEWRITER_SPEED = 30; // ms per character
const OVERLAY_DISMISS_MS = 5000; // default overlay auto-dismiss
const SCENARIO_DISMISS_MS = 6000; // scenario announcement duration
const COIN_RAIN_DEFAULT = 10; // default particle count
const INIT_RETRY_DELAY = 2000; // retry setup after Gradio loads
const DEBOUNCE_MS = 100; // observer debounce
// ── State ────────────────────────────────────────────────────────
let _previousHP = null;
let _previousMeta = null;
let _typewriterActive = false;
let _observerSetup = false;
let _lastNarrativeText = '';
let _lastChatCount = 0;
/* ═════════════════════════════════════════════════════════════════
1. TYPEWRITER EFFECT
═════════════════════════════════════════════════════════════════ */
function applyTypewriter(element) {
if (!element) return;
// Skip if already being typed
if (element.dataset.typing === 'true') return;
const fullHTML = element.innerHTML;
const fullText = element.textContent || '';
// Skip if empty or very short (likely a partial update)
if (fullText.trim().length < 2) return;
// Skip if text hasn't changed
if (fullText === element.dataset.lastTyped) return;
element.dataset.typing = 'true';
element.dataset.lastTyped = fullText;
_typewriterActive = true;
// Work with text content to avoid breaking HTML mid-tag
// Strategy: reveal characters by expanding a clip
const wrapper = document.createElement('span');
wrapper.className = 'typewriter-wrapper';
wrapper.innerHTML = fullHTML;
wrapper.style.visibility = 'hidden';
// Measure full content
element.innerHTML = '';
element.appendChild(wrapper);
// Use character-by-character for plain text,
// or progressive innerHTML reveal for rich content
const isRichContent = fullHTML !== fullText;
if (isRichContent) {
// For HTML content: progressive reveal
_typewriterRichReveal(element, fullHTML, fullText);
} else {
// For plain text: character by character
_typewriterPlainReveal(element, fullText);
}
}
function _typewriterPlainReveal(element, text) {
element.innerHTML = '';
let index = 0;
const cursor = document.createElement('span');
cursor.className = 'typewriter-cursor';
cursor.textContent = '|';
function typeNext() {
if (index < text.length) {
// Add characters in small chunks for performance
const chunk = text.substring(index, index + 2);
element.insertBefore(
document.createTextNode(chunk),
cursor
);
index += 2;
element.appendChild(cursor);
element.scrollTop = element.scrollHeight;
setTimeout(typeNext, TYPEWRITER_SPEED);
} else {
// Typing complete
element.dataset.typing = 'false';
_typewriterActive = false;
// Remove cursor after a delay
setTimeout(() => {
if (cursor.parentNode) cursor.remove();
}, 2000);
}
}
element.appendChild(cursor);
typeNext();
}
function _typewriterRichReveal(element, html, plainText) {
// For rich HTML: reveal by setting opacity on a wrapper
// and revealing character positions via max-width
element.innerHTML = '';
const container = document.createElement('div');
container.style.cssText = 'overflow:hidden; display:inline;';
container.innerHTML = html;
element.appendChild(container);
// Reveal the content progressively
const textNodes = _getTextNodes(container);
let totalRevealed = 0;
const totalChars = plainText.length;
// Initially hide all text
textNodes.forEach(node => {
const span = document.createElement('span');
span.style.visibility = 'hidden';
span.textContent = node.textContent;
span.dataset.fullText = node.textContent;
span.dataset.revealed = '0';
node.parentNode.replaceChild(span, node);
});
const hiddenSpans = container.querySelectorAll('span[data-full-text]');
let currentSpanIndex = 0;
let currentCharIndex = 0;
const cursor = document.createElement('span');
cursor.className = 'typewriter-cursor';
cursor.textContent = '|';
element.appendChild(cursor);
function revealNext() {
if (currentSpanIndex >= hiddenSpans.length) {
element.dataset.typing = 'false';
_typewriterActive = false;
setTimeout(() => {
if (cursor.parentNode) cursor.remove();
}, 2000);
return;
}
const span = hiddenSpans[currentSpanIndex];
const full = span.dataset.fullText;
currentCharIndex += 2;
if (currentCharIndex >= full.length) {
span.style.visibility = 'visible';
span.textContent = full;
currentSpanIndex++;
currentCharIndex = 0;
} else {
span.style.visibility = 'visible';
span.textContent = full.substring(0, currentCharIndex);
}
element.scrollTop = element.scrollHeight;
setTimeout(revealNext, TYPEWRITER_SPEED);
}
revealNext();
}
function _getTextNodes(element) {
const nodes = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while ((node = walker.nextNode())) {
if (node.textContent.trim().length > 0) {
nodes.push(node);
}
}
return nodes;
}
/* ═════════════════════════════════════════════════════════════════
2. MUTATION OBSERVER SETUP
═════════════════════════════════════════════════════════════════ */
function setupMutationObserver() {
if (_observerSetup) return;
const config = { childList: true, subtree: true, characterData: true };
let debounceTimer = null;
const observer = new MutationObserver((mutations) => {
// Debounce rapid updates
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
handleMutations(mutations);
}, DEBOUNCE_MS);
});
// Observe the entire Gradio app container
const appRoot = document.querySelector('gradio-app') ||
document.querySelector('.gradio-container') ||
document.body;
observer.observe(appRoot, config);
_observerSetup = true;
console.log('[Dokkaebi System] MutationObserver active.');
}
function handleMutations(mutations) {
// ── Narrative text changed → typewriter ──
const narrative = document.querySelector('.narrative-text');
if (narrative) {
const currentText = narrative.textContent || '';
if (currentText !== _lastNarrativeText && currentText.trim().length > 0) {
_lastNarrativeText = currentText;
applyTypewriter(narrative);
}
}
// ── Constellation chat changed → scroll + slide-in ──
const chat = document.querySelector('.constellation-chat');
if (chat) {
const messages = chat.querySelectorAll('.chat-message');
if (messages.length > _lastChatCount) {
// New messages added — animate them
for (let i = _lastChatCount; i < messages.length; i++) {
if (!messages[i].classList.contains('slide-in-right')) {
messages[i].classList.add('slide-in-right');
}
}
_lastChatCount = messages.length;
scrollStarStream();
}
}
// ── Fullscreen overlay appeared ──
const overlay = document.querySelector('.fullscreen-overlay');
if (overlay && overlay.innerHTML.trim().length > 0) {
const isVisible = overlay.offsetParent !== null ||
getComputedStyle(overlay).display !== 'none';
if (isVisible) {
// Check if it's a scenario announcement
if (overlay.querySelector('.scenario-announcement')) {
showScenarioAnnouncement();
}
// Check if it's Reader Noticed (class is on the overlay itself)
else if (overlay.classList.contains('reader-noticed')) {
showReaderNoticed();
}
// Generic overlay — auto-dismiss
else {
showOverlay(OVERLAY_DISMISS_MS);
}
}
}
// ── State effects from hidden component ──
const stateEl = document.getElementById('game-state-data');
if (stateEl) {
// Read data-state attribute (Python renders: data-state='...')
const stateJson = stateEl.dataset.state;
const triggersJson = stateEl.dataset.triggers;
if (stateJson && stateJson.trim().startsWith('{')) {
try {
handleStateEffects(stateJson, triggersJson);
} catch (e) { console.warn('[Dokkaebi] State parse error:', e); }
}
}
// ── Shake class cleanup ──
if (document.body.classList.contains('shake')) {
// Will be cleaned up by animationend listener
}
}
/* ═════════════════════════════════════════════════════════════════
3. EVENT DELEGATION
═════════════════════════════════════════════════════════════════ */
function setupEventDelegation() {
// ── Suggestion button clicks ──
document.addEventListener('click', (e) => {
const btn = e.target.closest('.suggestion-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const actionText = btn.dataset.action || btn.textContent.trim();
if (!actionText) return;
// Find Gradio textarea
const textarea = document.querySelector('.game-input textarea') ||
document.querySelector('.game-input input[type="text"]');
if (textarea) {
// Use native setter to trigger Gradio's reactive binding
const descriptor = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
) || Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
);
if (descriptor && descriptor.set) {
descriptor.set.call(textarea, actionText);
} else {
textarea.value = actionText;
}
// Dispatch events Gradio listens for
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
// Visual feedback on the button
btn.style.borderColor = '#904858';
btn.style.boxShadow = '0 0 10px rgba(192,104,120,0.25)';
btn.style.color = '#c06878';
// Click submit after a brief delay
setTimeout(() => {
const submitBtn = document.querySelector('.act-button') ||
document.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.click();
// Reset button style
setTimeout(() => {
btn.style.borderColor = '';
btn.style.boxShadow = '';
}, 200);
}, 100);
}
});
// ── Fullscreen overlay click dismiss ──
document.addEventListener('click', (e) => {
const overlay = e.target.closest('.fullscreen-overlay');
if (overlay && !overlay.classList.contains('reader-noticed')) {
dismissOverlay(overlay);
}
});
// ── Shake animation cleanup ──
document.addEventListener('animationend', (e) => {
if (e.animationName === 'shake') {
e.target.classList.remove('shake');
}
if (e.animationName === 'damageFlash') {
e.target.remove();
}
});
// ── Keyboard shortcut: Enter to submit ──
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
const textarea = document.querySelector('.game-input textarea');
if (textarea && document.activeElement === textarea) {
const value = textarea.value.trim();
if (value.length > 0) {
e.preventDefault();
const submitBtn = document.querySelector('.act-button');
if (submitBtn) submitBtn.click();
}
}
}
});
console.log('[Dokkaebi System] Event delegation active.');
}
/* ═════════════════════════════════════════════════════════════════
4. OVERLAY MANAGEMENT
═════════════════════════════════════════════════════════════════ */
function showOverlay(duration) {
duration = duration || OVERLAY_DISMISS_MS;
const overlay = document.querySelector('.fullscreen-overlay');
if (!overlay) return;
// Ensure it's visible
overlay.style.display = 'flex';
overlay.classList.add('fade-in');
// Auto-dismiss
setTimeout(() => {
dismissOverlay(overlay);
}, duration);
}
function dismissOverlay(overlay) {
overlay = overlay || document.querySelector('.fullscreen-overlay');
if (!overlay) return;
overlay.classList.remove('fade-in');
overlay.classList.add('fade-out');
setTimeout(() => {
overlay.style.setProperty('display', 'none', 'important');
overlay.classList.remove('fade-out');
// Remove the flash class if it was a scenario overlay
overlay.classList.remove('scenario-flash');
}, 500);
}
function showScenarioAnnouncement() {
const overlay = document.querySelector('.fullscreen-overlay');
if (!overlay) return;
// Add flash effect
overlay.classList.add('scenario-flash');
overlay.style.display = 'flex';
// Play a subtle screen effect
const announcement = overlay.querySelector('.scenario-announcement');
if (announcement) {
announcement.classList.add('scale-up');
}
// Auto-dismiss after duration
setTimeout(() => {
dismissOverlay(overlay);
if (announcement) {
announcement.classList.remove('scale-up');
}
overlay.classList.remove('scenario-flash');
}, SCENARIO_DISMISS_MS);
}
function showReaderNoticed() {
// Apply full-screen glitch — DO NOT auto-dismiss
document.body.classList.add('glitch-effect', 'static-noise');
const overlay = document.querySelector('.fullscreen-overlay');
if (overlay) {
overlay.style.display = 'flex';
overlay.classList.add('fade-in');
}
// Shake the screen
triggerScreenShake();
// Apply glitch text to the overlay content
const content = overlay ? overlay.querySelector('.reader-noticed') : null;
if (content) {
content.classList.add('glitch-text');
}
console.log('[Dokkaebi System] ⚠ The Reader has noticed you.');
}
/* ═════════════════════════════════════════════════════════════════
5. STAR STREAM AUTO-SCROLL
═════════════════════════════════════════════════════════════════ */
function scrollStarStream() {
const chat = document.querySelector('.constellation-chat');
if (chat) {
// Smooth scroll to bottom
requestAnimationFrame(() => {
chat.scrollTo({
top: chat.scrollHeight,
behavior: 'smooth'
});
});
}
}
/* ═════════════════════════════════════════════════════════════════
6. COIN RAIN PARTICLES
═════════════════════════════════════════════════════════════════ */
function spawnCoinRain(container, count) {
container = container || document.body;
count = count || COIN_RAIN_DEFAULT;
for (let i = 0; i < count; i++) {
const coin = document.createElement('div');
coin.className = 'coin-particle';
// Randomize position and timing
const startX = Math.random() * 100;
const duration = 1 + Math.random() * 1.5;
const delay = Math.random() * 0.5;
const size = 4 + Math.random() * 6;
coin.style.cssText = `
--start-x: ${startX}%;
--fall-duration: ${duration}s;
left: ${startX}%;
top: -10px;
width: ${size}px;
height: ${size}px;
animation-delay: ${delay}s;
position: absolute;
`;
container.style.position = container.style.position || 'relative';
container.appendChild(coin);
// Remove after animation
setTimeout(() => {
if (coin.parentNode) coin.remove();
}, (duration + delay) * 1000 + 200);
}
}
/* ═════════════════════════════════════════════════════════════════
7. SCREEN EFFECTS
═════════════════════════════════════════════════════════════════ */
function triggerDamageFlash() {
// Shake the screen
triggerScreenShake();
// Create red flash overlay
const flash = document.createElement('div');
flash.className = 'damage-flash-overlay';
document.body.appendChild(flash);
// Clean up after animation
setTimeout(() => {
if (flash.parentNode) flash.remove();
}, 500);
}
function triggerScreenShake() {
document.body.classList.add('shake');
setTimeout(() => {
document.body.classList.remove('shake');
}, 500);
}
function triggerGlitchEffect() {
document.body.classList.add('glitch-effect', 'static-noise');
// Permanent — Reader Noticed doesn't go away
}
function clearGlitchEffect() {
document.body.classList.remove('glitch-effect', 'static-noise');
}
/* ═════════════════════════════════════════════════════════════════
8. GAME STATE DRIVEN EFFECTS
═════════════════════════════════════════════════════════════════ */
function handleStateEffects(stateJson, triggersJson) {
let state, triggers;
try {
state = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
} catch (e) {
return;
}
try {
triggers = triggersJson && typeof triggersJson === 'string' ? JSON.parse(triggersJson) : (triggersJson || {});
} catch (e) {
triggers = {};
}
// ── Meta Exposure visual effects ──
if (typeof state.meta_exposure === 'number') {
document.body.classList.remove('meta-warning', 'meta-critical');
if (state.meta_exposure >= 75) {
document.body.classList.add('meta-critical');
// Pulse the meta bar
const metaBar = document.querySelector('.stat-bar-meta');
if (metaBar) metaBar.classList.add('meter-danger');
} else if (state.meta_exposure >= 50) {
document.body.classList.add('meta-warning');
const metaBar = document.querySelector('.stat-bar-meta');
if (metaBar) {
metaBar.classList.remove('meter-danger');
metaBar.classList.add('meter-pulse');
setTimeout(() => metaBar.classList.remove('meter-pulse'), 1000);
}
} else {
const metaBar = document.querySelector('.stat-bar-meta');
if (metaBar) {
metaBar.classList.remove('meter-danger', 'meter-pulse');
}
}
// Track meta changes
if (_previousMeta !== null && state.meta_exposure > _previousMeta) {
const metaFill = document.querySelector('.stat-bar-meta .stat-bar-fill');
if (metaFill) {
metaFill.classList.add('meter-pulse');
setTimeout(() => metaFill.classList.remove('meter-pulse'), 1000);
}
}
_previousMeta = state.meta_exposure;
}
// ── Phase visual effects ──
if (state.current_phase) {
document.body.classList.remove('phase-combat', 'phase-safe-zone', 'phase-exploration');
const phaseClass = 'phase-' + state.current_phase.toLowerCase().replace(/\s+/g, '-');
document.body.classList.add(phaseClass);
}
// ── HP damage detection (via triggers.damage) ──
if (typeof state.hp === 'number') {
const tookDamage = triggers.damage === true ||
(_previousHP !== null && state.hp < _previousHP);
if (tookDamage) {
triggerDamageFlash();
const hpBar = document.querySelector('.stat-bar-hp');
if (hpBar) {
const pct = state.hp / (state.max_hp || 100);
if (pct > 0.6) hpBar.dataset.percent = 'high';
else if (pct > 0.3) hpBar.dataset.percent = 'mid';
else hpBar.dataset.percent = 'low';
}
}
_previousHP = state.hp;
}
// ── Coin gain detection (via triggers.coins_gained) ──
if (triggers.coins_gained === true) {
const playerPanel = document.querySelector('.player-panel');
spawnCoinRain(playerPanel || document.body, 12);
}
// ── Rank display ──
if (state.show_rank) {
const rankDisplay = document.querySelector('.ranking-display');
if (rankDisplay) {
const rank = state.show_rank.toLowerCase();
rankDisplay.classList.add(`rank-${rank}`);
}
}
// ── Reader Noticed ──
if (state.reader_noticed) {
triggerGlitchEffect();
}
}
/* ═════════════════════════════════════════════════════════════════
9. STAT BAR ANIMATION HELPER
═════════════════════════════════════════════════════════════════ */
function animateStatBars() {
const fills = document.querySelectorAll('.stat-bar-fill');
fills.forEach(fill => {
const targetWidth = fill.style.width;
if (targetWidth && !fill.dataset.animated) {
// Force a reflow to trigger the CSS transition
fill.style.width = '0%';
fill.offsetHeight; // trigger reflow
fill.style.width = targetWidth;
fill.dataset.animated = 'true';
}
});
}
/* ═════════════════════════════════════════════════════════════════
10. START SCREEN EFFECTS
═════════════════════════════════════════════════════════════════ */
function setupStartScreen() {
const title = document.querySelector('.game-title');
if (title && !title.classList.contains('floating')) {
title.classList.add('floating');
}
const startBtn = document.querySelector('.start-btn');
if (startBtn && !startBtn.classList.contains('glow-pulse')) {
startBtn.classList.add('glow-pulse');
}
}
/* ═════════════════════════════════════════════════════════════════
11. ORV TEXT FORMATTING HELPER
═════════════════════════════════════════════════════════════════ */
/**
* Formats raw text with ORV-style markup:
* [Text] → <span class="bracket-text">[Text]</span>
* 'Skill Name' inside brackets → <span class="skill-name">'Skill Name'</span>
* 999 coins → <span class="coins-display">999</span>
*/
function formatORVText(text) {
if (!text) return text;
// Bracket text: [something]
text = text.replace(/\[([^\]]+)\]/g, (match, inner) => {
// Check for skill names within: 'Name'
const formatted = inner.replace(/'([^']+)'/g,
'<span class="skill-name">\'$1\'</span>'
);
return `<span class="bracket-text">[${formatted}]</span>`;
});
// Coin amounts: X coins
text = text.replace(/(\d+)\s*coins?/gi,
'<span class="coins-display">$1</span> coins'
);
return text;
}
/* ═════════════════════════════════════════════════════════════════
12. AMBIENT BACKGROUND EFFECTS
═════════════════════════════════════════════════════════════════ */
function spawnAmbientParticles() {
const container = document.querySelector('.game-area');
if (!container) return;
// Create a few floating particles for atmosphere
for (let i = 0; i < 5; i++) {
const particle = document.createElement('div');
const x = Math.random() * 100;
const y = Math.random() * 100;
const size = 1 + Math.random() * 2;
const duration = 8 + Math.random() * 12;
const delay = Math.random() * 5;
particle.style.cssText = `
position: absolute;
left: ${x}%;
top: ${y}%;
width: ${size}px;
height: ${size}px;
background: rgba(201, 168, 76, 0.12);
border-radius: 50%;
pointer-events: none;
z-index: 0;
animation: float ${duration}s ease-in-out ${delay}s infinite;
opacity: 0.3;
`;
container.style.position = container.style.position || 'relative';
container.appendChild(particle);
}
}
/* ═════════════════════════════════════════════════════════════════
13. AUDIO FEEDBACK (visual-only fallback)
═════════════════════════════════════════════════════════════════ */
/**
* Provides visual "click" feedback when audio isn't available.
* Creates a brief ripple at the click location.
*/
function clickRipple(e) {
const ripple = document.createElement('div');
ripple.style.cssText = `
position: fixed;
left: ${e.clientX - 15}px;
top: ${e.clientY - 15}px;
width: 30px;
height: 30px;
border: 1px solid rgba(74, 158, 255, 0.4);
border-radius: 50%;
pointer-events: none;
z-index: 99999;
animation: scaleUp 0.4s ease-out forwards;
opacity: 0.6;
`;
document.body.appendChild(ripple);
setTimeout(() => {
if (ripple.parentNode) ripple.remove();
}, 400);
}
/* ═════════════════════════════════════════════════════════════════
14. INITIALIZATION
═════════════════════════════════════════════════════════════════ */
function initialize() {
console.log('[Dokkaebi System] Initializing Scenario Simulator...');
setupMutationObserver();
setupEventDelegation();
setupStartScreen();
// Initial stat bar animation
setTimeout(animateStatBars, 500);
// Ambient particles (subtle)
setTimeout(spawnAmbientParticles, 1000);
console.log('[Dokkaebi System] Scenario Simulator initialized.');
console.log('[Dokkaebi System] ──────────────────────────────────');
console.log('[Dokkaebi System] "The scenario has begun."');
console.log('[Dokkaebi System] ──────────────────────────────────');
}
// ── Run on DOMContentLoaded ──
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
// DOM already loaded
initialize();
}
// ── Retry after Gradio finishes rendering ──
// Gradio rebuilds the DOM after initial load, so we need to re-init
setTimeout(() => {
if (!_observerSetup) {
console.log('[Dokkaebi System] Retry initialization (Gradio late load)...');
initialize();
}
// Re-check start screen
setupStartScreen();
// Re-animate stat bars
animateStatBars();
// Re-scroll chat
scrollStarStream();
}, INIT_RETRY_DELAY);
// Additional retry for very slow Gradio loads
setTimeout(() => {
setupStartScreen();
animateStatBars();
scrollStarStream();
}, 4000);
/* ═════════════════════════════════════════════════════════════════
15. EXPOSE API (for Gradio's inline script calls)
═════════════════════════════════════════════════════════════════ */
// Expose key functions globally so Gradio inline JS can call them
window.ORVGame = {
typewriter: applyTypewriter,
damageFlash: triggerDamageFlash,
screenShake: triggerScreenShake,
glitch: triggerGlitchEffect,
clearGlitch: clearGlitchEffect,
coinRain: spawnCoinRain,
scrollChat: scrollStarStream,
showOverlay: showOverlay,
dismissOverlay: dismissOverlay,
showScenario: showScenarioAnnouncement,
showReaderNoticed: showReaderNoticed,
handleState: handleStateEffects,
formatText: formatORVText,
animateBars: animateStatBars,
};
})();