/* ═══════════════════════════════════════════════════════════════════ 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] → [Text] * 'Skill Name' inside brackets → 'Skill Name' * 999 coins → 999 */ 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, '\'$1\'' ); return `[${formatted}]`; }); // Coin amounts: X coins text = text.replace(/(\d+)\s*coins?/gi, '$1 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, }; })();