1// ââââââââââââââââââââââââââââââââââââââââââââââ 2// UI â rendering helpers (extracted from monolith) 3// ââââââââââââââââââââââââââââââââââââââââââââââ 4 5import { labelOf } from './input.js'; 6import { t } from './i18n.js'; 7import { beep, sfxCoin, sfxCoinMulti, sfxRoundSuccess, sfxRoundFail, sfxFloorClear, 8 sfxStreakMilestone, sfxCheckPass, sfxCountdownTick, sfxGo, 9 sfxNewRecord, sfxUnlock, sfxLucky, sfxRepair, sfxTimerUrgent, 10 sfxUnlockDrumroll, sfxUnlockRevealHit, sfxUnlockComplete, 11 sfxContinue } from './audio.js'; 12 13/* ââ helpers âââââââââââââââââââââââââââââââ */ 14const $ = (id) => document.getElementById(id); 15function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } 16function rnd(a, b) { return a + Math.random() * (b - a); } 17 18/* ââ Confetti ââââââââââââââââââââââââââââââ */ 19 20export function spawnConfetti(count = 30) { 21 const container = $("confettiContainer"); 22 if (!container) return; 23 const colors = ['#ff2d95', '#00d4ff', '#39ff14', '#ffe600', '#b44dff', '#ff6a00', '#ff3388', '#00ffcc']; 24 const shapes = ['circle', 'square', 'triangle', 'star']; 25 for (let i = 0; i < count; i++) { 26 const el = document.createElement('div'); 27 el.className = 'confetti'; 28 const color = colors[Math.floor(Math.random() * colors.length)]; 29 const shape = shapes[Math.floor(Math.random() * shapes.length)]; 30 const size = 6 + Math.random() * 12; 31 el.style.left = Math.random() * 100 + '%'; 32 el.style.animationDelay = Math.random() * 0.5 + 's'; 33 el.style.animationDuration = 1.5 + Math.random() * 2 + 's'; 34 35 if (shape === 'circle') { 36 el.style.width = size + 'px'; el.style.height = size + 'px'; 37 el.style.borderRadius = '50%'; el.style.background = color; 38 el.style.boxShadow = `0 0 ${size/2}px ${color}40`; 39 } else if (shape === 'square') { 40 el.style.width = size + 'px'; el.style.height = size + 'px'; 41 el.style.borderRadius = '2px'; el.style.background = color; 42 el.style.transform = `rotate(${Math.random() * 360}deg)`; 43 } else if (shape === 'star') { 44 el.style.width = size + 'px'; el.style.height = size + 'px'; 45 el.style.background = color; 46 el.style.clipPath = 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)'; 47 el.style.boxShadow = `0 0 ${size}px ${color}60`; 48 } else { 49 el.style.width = '0'; el.style.height = '0'; 50 el.style.borderLeft = size / 2 + 'px solid transparent'; 51 el.style.borderRight = size / 2 + 'px solid transparent'; 52 el.style.borderBottom = size + 'px solid ' + color; 53 el.style.background = 'none'; 54 } 55 container.appendChild(el); 56 setTimeout(() => el.remove(), 3000); 57 } 58} 59 60/* ââ Countdown overlay âââââââââââââââââââââ */ 61 62const overlay = $("countOverlay"); 63const overlayNum = $("countNum"); 64 65export function showOverlayNumber(n) { 66 overlay.classList.add("show"); 67 overlayNum.textContent = String(n); 68 overlayNum.setAttribute("data-n", String(n)); 69 70 const rot = (Math.random() * 30 - 15).toFixed(1) + "deg"; 71 const tx = (Math.random() * 60 - 30).toFixed(1) + "px"; 72 const ty = (Math.random() * 60 - 30).toFixed(1) + "px"; 73 overlayNum.style.setProperty("--rot", rot); 74 overlayNum.style.setProperty("--tx", tx); 75 overlayNum.style.setProperty("--ty", ty); 76 77 overlayNum.classList.remove("animatePop"); 78 void overlayNum.offsetWidth; 79 overlayNum.classList.add("animatePop"); 80 81 sfxCountdownTick(n); 82 // Screen pulse on countdown 83 _screenPulse(n === 1 ? 'var(--neon-green)' : 'var(--neon-blue)', 0.08); 84} 85 86export function hideOverlay() { 87 overlay.classList.remove("show"); 88} 89 90/* ââ GO! Overlay âââââââââââââââââââââââââââ */ 91 92export function showGo() { 93 const el = $("goOverlay"); 94 el.classList.remove("show"); 95 void el.offsetWidth; 96 el.classList.add("show"); 97 sfxGo(); 98 spawnGoConfetti(); 99 _screenPulse('var(--neon-green)', 0.15); 100 setTimeout(() => el.classList.remove("show"), 500); 101} 102 103function spawnGoConfetti() { 104 let c = document.querySelector('.go-confetti'); 105 if (!c) { c = document.createElement('div'); c.className = 'go-confetti'; document.body.appendChild(c); } 106 c.innerHTML = ''; 107 const colors = ['#ff2d95', '#00d4ff', '#39ff14', '#ffe600', '#b44dff']; 108 for (let i = 0; i < 18; i++) { 109 const p = document.createElement('div'); 110 p.className = 'particle'; 111 const angle = (i / 18) * Math.PI * 2; 112 const dist = 80 + Math.random() * 140; 113 p.style.setProperty('--cx', `${Math.cos(angle) * dist}px`); 114 p.style.setProperty('--cy', `${Math.sin(angle) * dist}px`); 115 p.style.setProperty('--cr', `${Math.random() * 360}deg`); 116 p.style.width = (4 + Math.random() * 8) + 'px'; 117 p.style.height = (4 + Math.random() * 8) + 'px'; 118 p.style.background = colors[Math.floor(Math.random() * colors.length)]; 119 p.style.animationDelay = (Math.random() * 0.08) + 's'; 120 c.appendChild(p); 121 } 122 setTimeout(() => c.innerHTML = '', 700); 123} 124 125/* ââ BOOM flash ââââââââââââââââââââââââââââ */ 126 127export function triggerBoom() { 128 const el = $("boomFlash"); 129 el.classList.remove("show"); 130 void el.offsetWidth; 131 el.classList.add("show"); 132 sfxRoundFail(); 133 // Screen shake on fail 134 document.body.classList.remove('screen-shake'); 135 void document.body.offsetWidth; 136 document.body.classList.add('screen-shake'); 137 setTimeout(() => { el.classList.remove("show"); document.body.classList.remove('screen-shake'); }, 500); 138} 139 140/* ââ Hold label ââââââââââââââââââââââââââââ */ 141 142export function showHoldLabel(show) { 143 const el = $("holdLabel"); 144 if (!el) return; 145 if (show) el.classList.add("visible"); 146 else el.classList.remove("visible"); 147} 148 149/* ââ Summary card ââââââââââââââââââââââââââ */ 150 151export function showSummary({ score, round, maxStreak, level, mode, floor, coins, rank, isNewBest, newUnlocks, contentUnlocks, difficultyPreset }) { 152 const ov = $("summaryOverlay"); 153 if (!ov) return; 154 155 // Random death quip 156 const quipEl = $("sumQuip"); 157 if (quipEl) { 158 const idx = Math.floor(Math.random() * 100); 159 quipEl.textContent = t(`game_over.quips.q${idx}`); 160 } 161 162 $("sumScore").textContent = score; 163 $("sumRounds").textContent = round; 164 $("sumStreak").textContent = maxStreak; 165 const floorStr = floor ? ` â ${t('game_over.floor_label')} ${floor}` : "";
166 const diffLabel = difficultyPreset ? t(`difficulty.label_${difficultyPreset}`) : `Lvl ${level}`; 167 if (mode === "competitive") { 168 $("sumMode").textContent = `\uD83D\uDE08 Competitive â Standard${floorStr}`; 169 } else { 170 $("sumMode").textContent = `\uD83C\uDF89 Party â ${diffLabel}${floorStr}`; 171 } 172 $("sumLevel").textContent = floor || level; 173 const sumCoinsEl = $("sumCoins"); 174 if (sumCoinsEl) sumCoinsEl.textContent = coins || 0; 175 176 // Rank badge 177 const rankEl = $("sumRank"); 178 if (rankEl) { 179 if (rank && rank <= 10) { 180 rankEl.style.display = ""; 181 rankEl.textContent = t('game_over.rank', { rank }); 182 } else { 183 rankEl.style.display = "none"; 184 } 185 } 186 187 // New record badge 188 const badgeEl = $("sumBadge"); 189 if (badgeEl) { 190 if (isNewBest) { 191 badgeEl.style.display = ""; 192 badgeEl.textContent = t('game_over.new_record'); 193 } else { 194 badgeEl.style.display = "none"; 195 } 196 } 197 198 // Achievement unlocks earned 199 const unlockEl = $("sumUnlocks"); 200 if (unlockEl) { 201 if (newUnlocks && newUnlocks.length > 0) { 202 unlockEl.style.display = ""; 203 unlockEl.innerHTML = newUnlocks.map(u => `<span class="summary-unlock-tag">ð ${u.name}</span>`).join(" "); 204 } else { 205 unlockEl.style.display = "none"; 206 } 207 } 208 209 // Content unlocks (metaprogression â jokers/consumables/shop variants) 210 const contentEl = $("sumContentUnlocks"); 211 if (contentEl) { 212 if (contentUnlocks && contentUnlocks.length > 0) { 213 contentEl.style.display = ""; 214 let html = `<div class="summary-content-unlock">`; 215 html += `<div class="summary-content-unlock__title">${t('game_over.new_content')}</div>`; 216 for (const u of contentUnlocks) { 217 // Try to find the item definition for icon/name 218 const poolLabel = u.pool === 'joker' ? t('compendium.tab_jokers') : 219 u.pool === 'consumable' ? t('compendium.tab_consumables') : 220 t('compendium.tab_shop'); 221 html += ` 222 <div class="summary-content-unlock__item"> 223 <div class="summary-content-unlock__icon">${u._icon || 'ð'}</div> 224 <div class="summary-content-unlock__info"> 225 <div class="summary-content-unlock__name">${u._name || u.id}</div> 226 <div class="summary-content-unlock__pool">${poolLabel}</div> 227 <div class="summary-content-unlock__reason">${u.reason}</div> 228 </div> 229 </div>`; 230 } 231 html += `</div>`; 232 contentEl.innerHTML = html; 233 } else { 234 contentEl.style.display = "none"; 235 } 236 } 237 238 ov.classList.remove("show"); 239 void ov.offsetWidth; 240 ov.classList.add("show"); 241} 242 243export function hideSummary() { 244 const ov = $("summaryOverlay"); 245 if (ov) ov.classList.remove("show"); 246} 247 248/* ââ Reduce motion âââââââââââââââââââââââââ */ 249 250export function setReduceMotion(on) { 251 document.body.setAttribute("data-reduce-motion", on ? "true" : "false"); 252} 253 254/* ââ State pill ââââââââââââââââââââââââââââ */ 255 256const STATE_PILL_CLASS = { 257 PRE_FLOOR: "pill--state", 258 BUILD: "pill--active", 259 HOLD_CHECK: "pill--active", 260 ROUND_SUMMARY: "pill--state", 261 SHOP: "pill--state", 262 INVENTORY: "pill--state", 263 LEVEL_END: "pill--end", 264}; 265 266export function setState(s) { 267 const gc = $("gameCard"); 268 gc.setAttribute("data-state", s); 269 gc.classList.remove("countdown-active"); 270 const pill = $("statePill"); 271 const pillLabels = { BUILD: t('game.state.build'), HOLD_CHECK: t('game.state.hold'), PRE_FLOOR: t('game.state.pre_floor'), ROUND_SUMMARY: t('game.state.summary'), SHOP: t('game.state.shop'), INVENTORY: t('game.state.inventory'), LEVEL_END: t('game.state.end'), MENU: t('game.state.menu'), COUNTDOWN: t('game.state.countdown') }; 272 pill.textContent = pillLabels[s] || s; 273 pill.className = "pill pill--state"; 274 if (STATE_PILL_CLASS[s]) pill.classList.add(STATE_PILL_CLASS[s]); 275} 276 277export function updateSeedPill(seedStr) { 278 $('seedPill').textContent = t('game.seed', { seed: seedStr }); 279} 280 281/* ââ Target chips ââââââââââââââââââââââââââ */ 282 283export function renderTarget(target, confirmedSet, state, renderKbd = true) { 284 const wrap = $("targetChips"); 285 wrap.innerHTML = ""; 286 287 // When ordered build is active, show chips in sequence order 288 const orderedSeq = _vizFlags.orderedBuild ? (_vizFlags._orderedBuildSeq || []) : null; 289 const tArr = orderedSeq && orderedSeq.length > 0 290 ? orderedSeq 291 : [...target].sort((a, b) => labelOf(a).localeCompare(labelOf(b))); 292 293 // Amnesia: hide unconfirmed chips after delay 294 const amnesiaActive = !!_vizFlags._amnesiaHidden; 295 296 for (let i = 0; i < tArr.length; i++) {
297 const k = tArr[i]; 298 const chip = document.createElement("div"); 299 chip.className = "chip"; 300 if (confirmedSet.has(k)) { 301 chip.classList.add("good"); 302 chip.textContent = labelOf(k); 303 } else if (amnesiaActive) { 304 chip.classList.add("bad", "amnesia-hidden"); 305 chip.textContent = "?"; 306 } else { 307 chip.classList.add("bad"); 308 chip.textContent = labelOf(k); 309 } 310 // Phantom key indicator 311 if (_vizFlags._phantomKey === k && !confirmedSet.has(k)) { 312 chip.classList.add("phantom"); 313 } 314 // Ordered build: show index 315 if (orderedSeq && !confirmedSet.has(k) && !amnesiaActive) { 316 const idx = document.createElement("span"); 317 idx.className = "chip-order"; 318 idx.textContent = i + 1; 319 chip.prepend(idx); 320 } 321 wrap.appendChild(chip); 322 } 323 if (renderKbd) renderKbdViz(target, confirmedSet, state); 324} 325 326/* ââ Keyboard vizualisation ââââââââââââââââ */ 327 328const KBD_ROWS = [ 329 ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Backspace"], 330 ["Tab", "KeyQ", "KeyW", "KeyE", "KeyR", "KeyT", "KeyY", "KeyU", "KeyI", "KeyO", "KeyP"], 331 ["ControlLeft", "KeyA", "KeyS", "KeyD", "KeyF", "KeyG", "KeyH", "KeyJ", "KeyK", "KeyL", "Enter"], 332 ["ShiftLeft", "KeyZ", "KeyX", "KeyC", "KeyV", "KeyB", "KeyN", "KeyM", "ShiftRight"], 333 ["AltLeft", "Space", "AltRight", "ControlRight"] 334]; 335 336// Global mutator viz flags (set from game.js) 337let _vizFlags = {}; 338export function setVizFlags(flags) { _vizFlags = flags || {}; } 339 340export function renderKbdViz(target, confirmedSet, state) { 341 const viz = $("kbdViz"); 342 viz.innerHTML = ""; 343 const mirror = !!_vizFlags.mirrorKbd; 344 const hiddenCount = _vizFlags.hiddenKeys || 0; 345 346 // Pick which target keys to hide (fog mutator) 347 let hiddenKeys = new Set(); 348 if (hiddenCount > 0 && target.size > 1) { 349 const tArr = [...target]; 350 for (let i = 0; i < Math.min(hiddenCount, tArr.length - 1); i++) { 351 hiddenKeys.add(tArr[i]); 352 } 353 } 354 355 for (const row of KBD_ROWS) { 356 const rowEl = document.createElement("div"); 357 rowEl.className = "krow"; 358 const keys = mirror ? [...row].reverse() : row; 359 for (const k of keys) { 360 const el = document.createElement("div"); 361 el.className = "kkey"; 362 el.textContent = labelOf(k); 363 if (confirmedSet.has(k)) el.classList.add("on"); 364 if (target.has(k)) { 365 if (hiddenKeys.has(k)) { 366 el.classList.add("fog"); 367 } else { 368 el.classList.add("need"); 369 } 370 if (confirmedSet.has(k)) el.classList.add("confirmed"); 371 } 372 rowEl.appendChild(el); 373 } 374 viz.appendChild(rowEl); 375 } 376 if (mirror) viz.classList.add("mirror-mode"); else viz.classList.remove("mirror-mode"); 377} 378 379/* ââ Message box âââââââââââââââââââââââââââ */ 380 381export function setMsg(text, kind = "") { 382 const el = $("roundMsg"); 383 el.className = "msg" + (kind ? " " + kind : ""); 384 el.textContent = text; 385} 386 387/* ââ Stats bar âââââââââââââââââââââââââââââ */ 388 389export function updateStats({ level, round, score, streak, strikes, floor, coins, maxStrikes, scoreBreakdown }) { 390 $("lvlV").textContent = floor ? `${floor}` : String(level); 391 $("roundV").textContent = String(round); 392 $("scoreV").textContent = String(score); 393 $("streakV").textContent = String(streak); 394 395 // Strikes: show X/MAX 396 const strikesEl = $("strikesV"); 397 if (maxStrikes != null) { 398 strikesEl.textContent = `${strikes}/${maxStrikes}`; 399 } else { 400 strikesEl.textContent = String(strikes); 401 } 402 403 const coinsEl = $("coinsV"); 404 if (coinsEl) coinsEl.textContent = String(coins || 0); 405 406 // Score breakdown tooltip 407 if (scoreBreakdown) { 408 const scoreStatEl = $("scoreV").closest(".stat"); 409 if (scoreStatEl) { 410 scoreStatEl.title = `Base: ${scoreBreakdown.base || 0} | Speed: +${scoreBreakdown.speed || 0} | Dolor: +${scoreBreakdown.diff || 0} | Racha: +${scoreBreakdown.streak || 0}${scoreBreakdown.checks ? ` | Checks: +${scoreBreakdown.checks}` : ""}`; 411 } 412 } 413} 414 415/** Floating "+N coins" animation â now with particles */ 416export function floatCoinPopup(amount) { 417 const coinsEl = $("coinsV"); 418 if (!coinsEl || amount <= 0) return; 419 // Floating text 420 const pop = document.createElement("span"); 421 pop.className = "coin-float"; 422 pop.textContent = `+${amount} ðª`; 423 const parent = coinsEl.closest(".stat"); 424 if (!parent) return; 425 parent.style.position = "relative";
426 parent.appendChild(pop); 427 setTimeout(() => pop.remove(), 1100); 428 // Coin particles 429 _spawnMiniParticles(parent, amount >= 5 ? 8 : 4, 'var(--neon-orange)'); 430 // Stat glow 431 coinsEl.classList.remove('stat-glow-orange'); 432 void coinsEl.offsetWidth; 433 coinsEl.classList.add('stat-glow-orange'); 434 setTimeout(() => coinsEl.classList.remove('stat-glow-orange'), 600); 435 // Sound 436 if (amount >= 5) sfxCoinMulti(Math.min(amount, 6)); 437 else sfxCoin(); 438} 439 440/* ââ Progress bars âââââââââââââââââââââââââ */ 441 442export function setTimeBar(frac) { 443 $("timeBar").style.width = `${clamp(frac, 0, 1) * 100}%`; 444} 445 446export function setHoldProgress(frac, msLeft) { 447 $("holdBar").style.width = `${clamp(frac, 0, 1) * 100}%`; 448 $("holdPct").textContent = `${Math.round(clamp(frac, 0, 1) * 100)}%`; 449 $("holdMsLeft").textContent = (msLeft == null) ? "â" : `${Math.max(0, Math.ceil(msLeft))} ms`; 450} 451 452/* ââ Time bar urgency + HUD shake ââââââââââ */ 453 454export function setTimeBarUrgent(urgent) { 455 const timeBarEl = $("timeBar").parentElement; 456 const gc = $("gameCard"); 457 if (urgent) { 458 timeBarEl.classList.add("urgent"); 459 gc.classList.add("countdown-active"); 460 } else { 461 timeBarEl.classList.remove("urgent"); 462 gc.classList.remove("countdown-active"); 463 } 464} 465 466/* ââ Score pop animation âââââââââââââââââââ */ 467 468export function animateScorePop() { 469 const scoreEl = $("scoreV"); 470 scoreEl.classList.remove("score-pop"); 471 void scoreEl.offsetWidth; 472 scoreEl.classList.add("score-pop"); 473 // Sparkle particles on score change 474 const parent = scoreEl.closest(".stat"); 475 if (parent) _spawnMiniParticles(parent, 3, 'var(--neon-green)'); 476} 477 478/* ââ Shake card ââââââââââââââââââââââââââââ */ 479 480export function shakeCard() { 481 const card = $("gameCard"); 482 card.classList.remove("shake"); 483 void card.offsetWidth; 484 card.classList.add("shake"); 485} 486 487/* ââ Streak milestone celebration ââââââââââ */ 488 489export function celebrateStreak(streak) { 490 if (streak >= 3 && streak % 3 === 0) { 491 sfxStreakMilestone(streak); 492 const streakEl = $("streakV"); 493 if (streakEl) { 494 streakEl.classList.remove('streak-fire'); 495 void streakEl.offsetWidth; 496 streakEl.classList.add('streak-fire'); 497 setTimeout(() => streakEl.classList.remove('streak-fire'), 1200); 498 const parent = streakEl.closest(".stat"); 499 if (parent) _spawnMiniParticles(parent, 6, streak >= 10 ? 'var(--neon-yellow)' : 'var(--neon-purple)'); 500 } 501 if (streak >= 5) spawnConfetti(20 + streak * 2); 502 } 503} 504 505/* ââ New record celebration ââââââââââââââââ */ 506 507export function celebrateNewRecord() { 508 sfxNewRecord(); 509 spawnConfetti(60); 510 _screenPulse('var(--neon-yellow)', 0.25); 511 // Big sparkle text 512 _showBigText('ð NEW RECORD!', 'var(--neon-yellow)'); 513} 514 515/* ââ Unlock celebration ââââââââââââââââââââ */ 516 517export function celebrateUnlock(name) { 518 sfxUnlock(); 519 _showBigText(`ð ${name}`, 'var(--neon-purple)'); 520 spawnConfetti(30); 521} 522 523/* ââ Surprise reward popup âââââââââââââââââ */ 524 525export function showSurpriseReward(icon, text, coins) { 526 sfxLucky(); 527 spawnConfetti(25); 528 _screenPulse('var(--neon-yellow)', 0.12); 529 const el = document.createElement('div'); 530 el.className = 'surprise-reward'; 531 el.innerHTML = ` 532 <div class="surprise-reward__icon">${icon}</div> 533 <div class="surprise-reward__text">${text}</div> 534 ${coins ? `<div class="surprise-reward__coins">+${coins} ðª</div>` : ''} 535 `; 536 document.body.appendChild(el); 537 setTimeout(() => { el.classList.add('surprise-reward--out'); }, 2000); 538 setTimeout(() => el.remove(), 2800); 539} 540 541/* ââ Screen pulse flash ââââââââââââââââââââ */ 542 543function _screenPulse(color, opacity = 0.1) { 544 let el = document.querySelector('.screen-pulse'); 545 if (!el) { 546 el = document.createElement('div'); 547 el.className = 'screen-pulse'; 548 document.body.appendChild(el); 549 } 550 el.style.setProperty('--pulse-color', color); 551 el.style.setProperty('--pulse-opacity', opacity); 552 el.classList.remove('active'); 553 void el.offsetWidth; 554 el.classList.add('active'); 555 setTimeout(() => el.classList.remove('active'), 400); 556} 557 558/* ââ Big text overlay ââââââââââââââââââââââ */ 559 560function _showBigText(text, color) { 561 const el = document.createElement('div'); 562 el.className = 'big-text-popup'; 563 el.style.color = color; 564 el.style.textShadow = `0 0 30px ${color}, 0 0 60px ${color}`; 565 el.textContent = text; 566 document.body.appendChild(el); 567 setTimeout(() => el.remove(), 1800); 568} 569 570/* ââ Mini particles (spawn from element) âââ */ 571 572function _spawnMiniParticles(parentEl, count, color) { 573 for (let i = 0; i < count; i++) { 574 const p = document.createElement('div'); 575 p.className = 'mini-particle'; 576 p.style.setProperty('--mp-x', `${rnd(-40, 40)}px`); 577 p.style.setProperty('--mp-y', `${rnd(-50, -10)}px`); 578 p.style.setProperty('--mp-r', `${rnd(0, 360)}deg`); 579 p.style.setProperty('--mp-color', color); 580 p.style.animationDelay = `${rnd(0, 0.12)}s`; 581 const size = rnd(3, 7); 582 p.style.width = size + 'px'; 583 p.style.height = size + 'px'; 584 parentEl.appendChild(p); 585 setTimeout(() => p.remove(), 800); 586 } 587} 588 589/* ââ Comic Splash animation ââââââââââââââââ */ 590 591/** 592 * Full-screen comic-style splash: huge icon + tilted name, no borders. 593 * @param {string} icon â emoji/icon 594 * @param {string} name â item/effect name 595 * @param {'acquire'|'use'|'rare'|'reward'} [type='acquire'] â visual variant 596 */ 597export function comicSplash(icon, name, type = 'acquire') { 598 const el = document.createElement('div'); 599 el.className = `comic-splash comic-splash--${type}`; 600 el.innerHTML = ` 601 <span class="comic-splash__icon">${icon}</span> 602 <span class="comic-splash__name">${name}</span> 603 <div class="comic-splash__burst"></div> 604 `; 605 document.body.appendChild(el); 606 // Force reflow then add 'in' class 607 void el.offsetWidth; 608 el.classList.add('comic-splash--in'); 609 const duration = type === 'rare' ? 1800 : type === 'use' ? 1100 : 1400; 610 setTimeout(() => el.classList.add('comic-splash--out'), duration); 611 setTimeout(() => el.remove(), duration + 500); 612} 613 614/** @deprecated â use comicSplash instead, kept for compat */ 615export function animateItemAcquired(icon, name, rarity = 'common') { 616 const type = rarity === 'rare' ? 'rare' : 'acquire'; 617 comicSplash(icon, name, type); 618} 619 620/* ââ Epic Unlock Reveal Ceremony âââââââââââ */ 621 622/** 623 * Full-screen cinematic unlock sequence. Shows each content unlock 624 * one by one with dramatic buildup, then calls onDone. 625 * @param {Array<{_icon: string, _name: string, pool: string, reason: string}>} unlocks 626 * @param {() => void} onDone â called when player dismisses the reveal 627 */ 628export function showUnlockReveal(unlocks, onDone) { 629 if (!unlocks || unlocks.length === 0) { onDone?.(); return; } 630 631 let ov = $("unlockRevealOverlay"); 632 if (!ov) { 633 ov = document.createElement("div"); 634 ov.id = "unlockRevealOverlay"; 635 ov.className = "unlock-reveal-overlay"; 636 document.body.appendChild(ov); 637 } 638 639 let currentIdx = 0; 640 const total = unlocks.length; 641 642 function _renderCard(idx) { 643 const u = unlocks[idx]; 644 const poolLabel = u.pool === 'joker' ? t('compendium.tab_jokers') : 645 u.pool === 'consumable' ? t('compendium.tab_consumables') : 646 t('compendium.tab_shop'); 647 648 ov.innerHTML = ` 649 <div class="unlock-reveal__particles" id="urParticles"></div> 650 <div class="unlock-reveal__rays"></div> 651 <div class="unlock-reveal__header">
652 <span class="unlock-reveal__counter">${idx + 1} / ${total}</span> 653 <span class="unlock-reveal__title">${t('game_over.new_content')}</span> 654 </div> 655 <div class="unlock-reveal__card" id="urCard"> 656 <div class="unlock-reveal__icon-wrap"> 657 <div class="unlock-reveal__icon-glow"></div> 658 <div class="unlock-reveal__icon" id="urIcon">${u._icon || 'ð'}</div> 659 </div> 660 <div class="unlock-reveal__name" id="urName">${u._name || u.id}</div> 661 <div class="unlock-reveal__pool">${poolLabel}</div> 662 <div class="unlock-reveal__reason">${u.reason || ''}</div> 663 <div class="unlock-reveal__available">${t('unlock_reveal.available_next')}</div> 664 </div> 665 <div class="unlock-reveal__action"> 666 <button class="unlock-reveal__btn" id="urBtn"> 667 ${idx < total - 1 ? t('unlock_reveal.next') : t('unlock_reveal.continue')} 668 </button> 669 </div> 670 `; 671 672 // Phase 1: Show overlay with drumroll 673 ov.classList.remove("show", "reveal"); 674 void ov.offsetWidth; 675 ov.classList.add("show"); 676 sfxUnlockDrumroll(); 677 678 // Phase 2: After drumroll builds, slam the card in 679 setTimeout(() => { 680 ov.classList.add("reveal"); 681 sfxUnlockRevealHit(); 682 spawnConfetti(50 + idx * 20); 683 _spawnRevealParticles(); 684 }, 800); 685 686 // Bind next/continue button 687 const btn = $("urBtn"); 688 const advance = () => { 689 btn.removeEventListener("click", advance); 690 document.removeEventListener("keydown", _keyHandler); 691 sfxContinue(); 692 693 if (idx < total - 1) { 694 ov.classList.remove("reveal"); 695 ov.classList.add("exit"); 696 setTimeout(() => { 697 ov.classList.remove("exit"); 698 currentIdx++; 699 _renderCard(currentIdx); 700 }, 350); 701 } else { 702 // Final â close the whole reveal 703 sfxUnlockComplete(); 704 ov.classList.remove("reveal"); 705 ov.classList.add("exit"); 706 setTimeout(() => { 707 ov.classList.remove("show", "exit"); 708 ov.innerHTML = ""; 709 onDone?.(); 710 }, 400); 711 } 712 }; 713 btn.addEventListener("click", advance); 714 715 const _keyHandler = (e) => { 716 if (e.key === "Enter" || e.key === " " || e.key === "Escape") { 717 e.preventDefault(); 718 advance(); 719 } 720 }; 721 // Bind after a short delay so we don't immediately skip 722 setTimeout(() => document.addEventListener("keydown", _keyHandler), 1200); 723 } 724 725 function _spawnRevealParticles() { 726 const container = $("urParticles"); 727 if (!container) return; 728 const colors = ['#b44dff', '#00d4ff', '#39ff14', '#ffe600', '#ff2d95', '#fff']; 729 for (let i = 0; i < 30; i++) { 730 const p = document.createElement('div'); 731 p.className = 'unlock-reveal__particle'; 732 const angle = Math.random() * Math.PI * 2; 733 const dist = 100 + Math.random() * 200; 734 const x = Math.cos(angle) * dist; 735 const y = Math.sin(angle) * dist; 736 const size = 3 + Math.random() * 6; 737 p.style.setProperty('--ur-x', `${x}px`); 738 p.style.setProperty('--ur-y', `${y}px`); 739 p.style.setProperty('--ur-size', `${size}px`); 740 p.style.setProperty('--ur-color', colors[Math.floor(Math.random() * colors.length)]); 741 p.style.animationDelay = `${Math.random() * 0.3}s`; 742 container.appendChild(p); 743 setTimeout(() => p.remove(), 1200); 744 } 745 } 746 747 _renderCard(0); 748} 749 750/* ââ Hold bar snap effect ââââââââââââââââââ */ 751 752export function snapHoldBar() { 753 const holdBar2 = $("holdBar2"); 754 if (!holdBar2) return; 755 holdBar2.classList.remove("snap"); 756 void holdBar2.offsetWidth; 757 holdBar2.classList.add("snap"); 758 setTimeout(() => holdBar2.classList.remove("snap"), 400); 759} 760 761/* ââ Button states âââââââââââââââââââââââââ */ 762 763export function setButtons({ startDisabled, stopDisabled }) { 764 $("startBtn").disabled = startDisabled; 765 $("stopBtn").disabled = stopDisabled; 766} 767 768/* ââ Check prompt (HOLD_CHECK phase) âââââââ */ 769 770export function showCheckPrompt(seq, current, total, windowMs) { 771 let wrap = $("checkPrompt"); 772 if (!wrap) { 773 wrap = document.createElement("div"); 774 wrap.id = "checkPrompt"; 775 wrap.className = "check-prompt"; 776 const gc = $("gameCard"); 777 const hint = gc.querySelector(".hint"); 778 if (hint) hint.after(wrap); 779 else gc.appendChild(wrap); 780 } 781 wrap.style.display = ""; 782 783 const seqLen = seq.length; 784 const seqLabel = seqLen > 1 ? ` (Ã${seqLen})` : "";
785 const keysHtml = seq.map(k => 786 `<span class="check-key">${labelOf(k)}</span>` 787 ).join(`<span class="check-arrow">â</span>`); 788 789 // Clear progress counter (always visible: "6/12") 790 const passed = current - 1; 791 const counterHtml = `<span class="check-prompt__counter">${passed}<span class="check-prompt__counter-sep">/</span>${total}</span>`; 792 793 // Compact dots only when total is manageable 794 let dotsHtml = ""; 795 if (total <= 14) { 796 dotsHtml = Array.from({ length: total }, (_, i) => { 797 let cls = "check-dot"; 798 if (i < passed) cls += " check-dot--done"; 799 else if (i === passed) cls += " check-dot--active"; 800 return `<span class="${cls}"></span>`; 801 }).join(""); 802 } 803 804 // Overall check progress bar 805 const progressPct = (passed / Math.max(1, total)) * 100; 806 807 wrap.innerHTML = ` 808 <div class="check-prompt__header"> 809 <span class="check-prompt__badge">HOLD${seqLabel}</span> 810 ${counterHtml} 811 ${dotsHtml ? `<span class="check-prompt__dots">${dotsHtml}</span>` : ""} 812 </div> 813 <div class="check-prompt__progress-wrap"> 814 <div class="check-prompt__progress-bar" style="width:${progressPct}%"></div> 815 </div> 816 <div class="check-prompt__keys">${keysHtml}</div> 817 <div class="check-prompt__timer-wrap"> 818 <div class="check-prompt__timer-bar" id="checkTimerBar"></div> 819 </div> 820 <div class="check-prompt__hint">¡Pulsa ahora!</div> 821 `; 822 wrap.classList.remove("check-pop", "check-pass-flash"); 823 void wrap.offsetWidth; 824 wrap.classList.add("check-pop"); 825} 826 827/** Update the per-check timer bar inside the check prompt (frac 0â1). */ 828export function updateCheckBar(frac) { 829 const bar = $("checkTimerBar"); 830 if (!bar) return; 831 const pct = clamp(frac, 0, 1) * 100; 832 bar.style.width = `${pct}%`; 833 // Color shift: green â yellow â red 834 if (frac < 0.5) bar.style.background = "var(--neon-green)"; 835 else if (frac < 0.75) bar.style.background = "var(--neon-yellow, #ffe600)"; 836 else bar.style.background = "var(--neon-pink)"; 837} 838 839export function hideCheckPrompt() { 840 const wrap = $("checkPrompt"); 841 if (wrap) wrap.style.display = "none"; 842} 843 844/** Flash the check prompt green on successful check. */ 845export function flashCheckPass() { 846 const wrap = $("checkPrompt"); 847 if (!wrap) return; 848 wrap.classList.remove("check-pass-flash"); 849 void wrap.offsetWidth; 850 wrap.classList.add("check-pass-flash"); 851 sfxCheckPass(); 852} 853 854/* ââ Export floor clear SFX ââââââââââââââââ */ 855export { sfxFloorClear, sfxRoundSuccess, sfxRepair, sfxNewRecord };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.