PageSourceSearch

https://seleccionados.es/keytwister/js/ui.js

js seleccionados.es collected 2026-10-02 01:53:34 UTC 31,172 bytes, 855 lines download raw bytes

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.