PageSourceSearch

https://coding-campus.ai/static/js/keyboard_beat.js?v=179121056320260518_17

js coding-campus.ai collected 2026-10-05 15:30:47 UTC 124,274 bytes, 3,488 lines download raw bytes

1/**
2 * ---
3 * Project      : Coding Campus Platform
4 * Module       : Static / Js
5 * File         : keyboard_beat.js
6 *
7 * Description  : Keyboard Beat — PLAY LAB 리듬 타자 MVP
8 *
9 * Author       : Jihun Kang
10 * Organization : Coding Campus / OrbiSync
11 * Contact      : [email protected]
12 *
13 * Copyright (c) 2026 Jihun Kang. All rights reserved.
14 * ---
15 */
16/**
17 * Keyboard Beat — PLAY LAB 리듬 타자 MVP
18 * Web Audio API만 사용 (외부 음원 없음).
19 */
20(function () {
21  "use strict";
22
23  function updateAppHeight() {
24    try {
25      var h = window.innerHeight;
26      if (window.visualViewport && window.visualViewport.height > 0) {
27        h = Math.round(window.visualViewport.height);
28      }
29      document.documentElement.style.setProperty("--app-height", h + "px");
30    } catch (eAppH) {
31      /* ignore */
32    }
33  }
34
35  function onBeatViewportChange() {
36    updateAppHeight();
37    if (state.phase === "playing") {
38      syncBeatPlayfieldLayout(true, "viewportChange");
39    }
40  }
41
42  function beatQuery(selector, root) {
43    var scope = root || document;
44    try {
45      return scope.querySelector(selector);
46    } catch (eQ) {
47      return null;
48    }
49  }
50
51  function setTextById(id, value) {
52    var el = document.getElementById(id);
53    if (!el) {
54      console.warn("[Keyboard Beat] missing element:", id);
55      return;
56    }
57    el.textContent = value != null ? String(value) : "";
58  }
59
60  function bindClick(el, handler, label) {
61    if (!el || typeof handler !== "function") {
62      if (label) console.warn("[Keyboard Beat] missing element for click:", label);
63      return;
64    }
65    el.addEventListener("click", handler);
66  }
67
68  function logBeatDomSnapshot(phase) {
69    if (typeof console === "undefined" || !console.debug) return;
70    console.debug("[Keyboard Beat] " + phase, {
71      panelActive: isBeatPanelActive(),
72      intro: !!beatQuery("#kbSetup:not(.is-hidden)"),
73      board: !!beatQuery(".kb-board"),
74      stage: !!beatQuery(".kb-lane__stage"),
75      startButton: !!beatQuery("#kbStartBtn"),
76      keypad: !!beatQuery("#kbTouchRow"),
77      gameVisible: !!beatQuery("#kbGame:not(.is-hidden)"),
78    });
79  }
80
81  function isBeatPanelActive() {
82    var panel = document.getElementById("playLabPanelKeyboardBeat");
83    return !!(panel && panel.classList.contains("is-active"));
84  }
85
86  function parkBeatPanelUi() {
87    setBeatPlayingLayout(false);
88    beatChrome.teardown();
89    closeKbRankingLayer();
90    closeKbSettingsLayer();
91    closeKbBeatPreviewModal();
92    if (els.setup) {
93      els.setup.classList.add("is-hidden");
94      els.setup.setAttribute("aria-hidden", "true");
95    }
96    if (els.game) els.game.classList.add("is-hidden");
97    if (els.countdown) els.countdown.classList.add("is-hidden");
98    if (els.resultOverlay) els.resultOverlay.classList.add("is-hidden");
99    if (els.touchRow) {
100      els.touchRow.classList.add("is-hidden");
101      els.touchRow.innerHTML = "";
102    }
103  }
104
105  /* -------------------------------------------------------------------------- */
106  /* Key sets & difficulty (BPM)                                                */
107  /* -------------------------------------------------------------------------- */
108
109  var KEY_SETS = {
110    home: ["A", "S", "D", "F", "J", "K", "L", ";"],
111    left: ["Q", "W", "E", "R", "T", "A", "S", "D", "F", "G", "Z", "X", "C", "V", "B"],
112    right: ["Y", "U", "I", "O", "P", "H", "J", "K", "L", "N", "M"],
113    numbers: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
114    symbols: ["(", ")", "{", "}", "[", "]", ";", '"', "'", "=", "+", "-", "*", "/", "<", ">"],
115  };
116
117  var BPM_BY_DIFF = { easy: 90, normal: 110, hard: 130 };
118
119  var LEVEL_DURATION = 30;
120  var COUNTDOWN_SEC = 3;
121  var TRAVEL_MS = 1600;
122  var FALL_SPEED_TRAVEL_NUMERATOR = 168000;
123
124  var BEAT_DEBUG = false;
125  try {
126    BEAT_DEBUG =
127      /(?:\?|&)kb_debug=1(?:&|$)/.test(window.location.search) ||
128      window.localStorage.getItem("keyboard_beat_debug") === "1";
129  } catch (eDbg) {
130    BEAT_DEBUG = false;
131  }
132
133  function beatStringifySafe(value) {
134    if (value == null || typeof value !== "object") return String(value);
135    try {
136      return JSON.stringify(value);
137    } catch (eStr) {
138      return String(value);
139    }
140  }
141
142  function beatDebugLog() {
143    if (!BEAT_DEBUG || typeof console === "undefined" || !console.log) return;
144    var parts = ["[Keyboard Beat debug]"];
145    for (var i = 0; i < arguments.length; i++) {
146      parts.push(beatStringifySafe(arguments[i]));
147    }
148    console.log(parts.join(" "));
149  }
150
151  /** 레벨별 키 풀 — 단계적 확장 (resolveBeatKeysForLevel에서 적용) */
152  var BEAT_KEY_PROGRESSION = [
153    ["A", "S", "D", "F"],
154    ["A", "S", "D", "F", "J", "K", "L"],
155    ["A", "S", "D", "F", "G", "H", "J", "K", "L"],
156    ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";"],
157    ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";", "Q", "W", "E", "R", "T", "Y"],
158    ["A", "S", "D", "F", "G", "H", "J", "K", "L", ";", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
159    [
160      "A", "S", "D", "F", "G", "H", "J", "K", "L", ";", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "Z",
161      "X", "C", "V", "B", "N", "M",
162    ],
163  ];
164
165  /**
166   * 레벨별 속도·스폰·정확도 (minSpeed/maxSpeed = 낙하 배율, baseFall = 기준 fallSpeed)
167   */
168  var BEAT_LEVELS = [
169    { level: 1, baseFall: 68, minSpeed: 1.4, maxSpeed: 1.8, spawnInterval: 1650, requiredAccuracy: 60 },
170    { level: 2, baseFall: 74, minSpeed: 1.6, maxSpeed: 2.0, spawnInterval: 1520, requiredAccuracy: 65 },
171    { level: 3, baseFall: 80, minSpeed: 1.8, maxSpeed: 2.2, spawnInterval: 1400, requiredAccuracy: 70 },
172    { level: 4, baseFall: 86, minSpeed: 2.0, maxSpeed: 2.5, spawnInterval: 1280, requiredAccuracy: 75 },
173    { level: 5, baseFall: 92, minSpeed: 2.2, maxSpeed: 2.8, spawnInterval: 1180, requiredAccuracy: 78 },
174    { level: 6, baseFall: 98, minSpeed: 2.38, maxSpeed: 3.02, spawnInterval: 1100, requiredAccuracy: 80 },
175    { level: 7, baseFall: 104, minSpeed: 2.56, maxSpeed: 3.24, spawnInterval: 1020, requiredAccuracy: 82 },
176    { level: 8, baseFall: 110, minSpeed: 2.74, maxSpeed: 3.46, spawnInterval: 940, requiredAccuracy: 83 },
177    { level: 9, baseFall: 116, minSpeed: 2.92, maxSpeed: 3.68, spawnInterval: 880, requiredAccuracy: 85 },
178    { level: 10, baseFall: 122, minSpeed: 3.1, maxSpeed: 3.9, spawnInterval: 820, requiredAccuracy: 85 },
179  ];
180
181  var PERFECT_LO = 0.93;
182  var PERFECT_HI = 1.06;
183  var GOOD_LO = 0.82;
184  var GOOD_HI = 1.18;
185
186  /** MISS: 노트 top이 기준선 bottom을 완전히 지난 뒤에만 (client px) */
187  var MISS_TOLERANCE = 4;
188  var FRAME_DELTA_MS_CAP = 32;
189  var KB_API_RANKING = "/api/play-lab/keyboard-beat/ranking";
190  var KB_API_LEADERBOARD = "/api/play-lab/keyboard-beat/ranking/leaderboard";
191  var KB_API_POSITION = "/api/play-lab/keyboard-beat/ranking/position";
192  /** Keyboard Race 결과 모달과 동일한 표시 이름 저장 키 (PLAY LAB 공유) */
193  var KB_RANKING_DISPLAY_NAME_KEY = "keyboardRaceRankingDisplayNameV1";
194
195  var SCORE_PERFECT = 100;
196  var SCORE_GOOD = 55;
197  var COMBO_BONUS = 2;
198
199  /** 난이도별 점수 배율 (레벨 scoreMultiplier와 곱함) */
200  var DIFF_SCORE_MULT = { easy: 1.0, normal: 1.3, hard: 1.6 };
201
202  /** MISS 시 점수 감점 기본값 × 난이도 배율 */
203  var MISS_SCORE_PENALTY_BASE = 30;
204  var DIFF_MISS_SCORE_MULT = { easy: 1.0, normal: 1.2, hard: 1.5 };
205
206  function alphaKeys() {
207    var a = [];
208    for (var c = 65; c <= 90; c++) a.push(String.fromCharCode(c));
209    return a;
210  }
211
212  /**
213   * Keyboard Beat 레벨 1~10
214   * musicTrack / musicBpm: 추후 AudioManager로 비트·트랙 동기화 예정 (현 단계에서는 미사용).
215   */
216  var KEYBOARD_BEAT_LEVELS = [
217    {
218      id: 1,
219      title: "왼손 홈 키 기초",
220      keys: ["A", "S", "D", "F"],
221      pattern: "repeat",
222      randomRatio: 0.3,
223      speedMultiplier: 1.0,
224      minSpeedMultiplier: 0.85,
225      maxSpeedMultiplier: 1.15,
226      spawnMultiplier: 1.0,
227      scoreMultiplier: 1.0,
228      musicTrack: "beginner_edm",
229      musicBpm: 85,
230      clearAccuracy: 70,
231      clearScore: 200,
232    },
233    {
234      id: 2,
235      title: "오른손 홈 키 기초",
236      keys: ["J", "K", "L", ";"],
237      pattern: "repeat",
238      randomRatio: 0.3,
239      speedMultiplier: 1.05,
240      minSpeedMultiplier: 0.9,
241      maxSpeedMultiplier: 1.2,
242      spawnMultiplier: 0.98,
243      scoreMultiplier: 1.1,
244      musicTrack: "beginner_edm",
245      musicBpm: 90,
246      clearAccuracy: 72,
247      clearScore: 250,
248    },
249    {
250      id: 3,
251      title: "홈 포지션 전체",
252      keys: ["A", "S", "D", "F", "J", "K", "L", ";"],
253      pattern: "basic",
254      randomRatio: 0.5,
255      speedMultiplier: 1.1,
256      minSpeedMultiplier: 0.95,
257      maxSpeedMultiplier: 1.3,
258      spawnMultiplier: 0.95,
259      scoreMultiplier: 1.2,
260      musicTrack: "beginner_edm",
261      musicBpm: 95,
262      clearAccuracy: 75,
263      clearScore: 300,
264    },
265    {
266      id: 4,
267      title: "좌우 손 교차",
268      keys: ["A", "S", "D", "F", "J", "K", "L", ";"],
269      pattern: "alternate",
270      randomRatio: 0.55,
271      speedMultiplier: 1.15,
272      minSpeedMultiplier: 1.0,
273      maxSpeedMultiplier: 1.4,
274      spawnMultiplier: 0.92,
275      scoreMultiplier: 1.35,
276      musicTrack: "groove_edm",
277      musicBpm: 105,
278      clearAccuracy: 78,
279      clearScore: 350,
280    },
281    {
282      id: 5,
283      title: "홈 키 랜덤",
284      keys: ["A", "S", "D", "F", "J", "K", "L", ";"],
285      pattern: "random_home",
286      randomRatio: 0.7,
287      speedMultiplier: 1.25,
288      minSpeedMultiplier: 1.05,
289      maxSpeedMultiplier: 1.5,
290      spawnMultiplier: 0.88,
291      scoreMultiplier: 1.5,
292      musicTrack: "groove_edm",
293      musicBpm: 110,
294      clearAccuracy: 80,
295      clearScore: 400,
296    },
297    {
298      id: 6,
299      title: "윗줄 키 추가",
300      keys: ["Q", "W", "E", "R", "U", "I", "O", "P", "A", "S", "D", "F", "J", "K", "L", ";"],
301      pattern: "top_row",
302      randomRatio: 0.7,
303      speedMultiplier: 1.35,
304      minSpeedMultiplier: 1.1,
305      maxSpeedMultiplier: 1.6,
306      spawnMultiplier: 0.84,
307      scoreMultiplier: 1.7,
308      musicTrack: "groove_edm",
309      musicBpm: 115,
310      clearAccuracy: 82,
311      clearScore: 500,
312    },
313    {
314      id: 7,
315      title: "아랫줄 키 추가",
316      keys: ["Z", "X", "C", "V", "N", "M", "A", "S", "D", "F", "J", "K", "L", ";"],
317      pattern: "bottom_row",
318      randomRatio: 0.75,
319      speedMultiplier: 1.45,
320      minSpeedMultiplier: 1.15,
321      maxSpeedMultiplier: 1.7,
322      spawnMultiplier: 0.8,
323      scoreMultiplier: 1.9,
324      musicTrack: "groove_edm",
325      musicBpm: 120,
326      clearAccuracy: 82,
327      clearScore: 600,
328    },
329    {
330      id: 8,
331      title: "전체 알파벳",
332      keys: alphaKeys(),
333      pattern: "all_random",
334      randomRatio: 0.85,
335      speedMultiplier: 1.55,
336      minSpeedMultiplier: 1.2,
337      maxSpeedMultiplier: 1.85,
338      spawnMultiplier: 0.76,
339      scoreMultiplier: 2.2,
340      musicTrack: "challenge_edm",
341      musicBpm: 130,
342      clearAccuracy: 83,
343      clearScore: 750,
344    },
345    {
346      id: 9,
347      title: "짧은 단어 패턴",
348      keys: alphaKeys(),
349      pattern: "word",
350      randomRatio: 0.6,
351      speedMultiplier: 1.65,
352      minSpeedMultiplier: 1.3,
353      maxSpeedMultiplier: 2.0,
354      spawnMultiplier: 0.72,
355      scoreMultiplier: 2.5,
356      musicTrack: "challenge_edm",
357      musicBpm: 140,
358      clearAccuracy: 84,
359      clearScore: 900,
360    },
361    {
362      id: 10,
363      title: "챌린지",
364      keys: alphaKeys(),
365      pattern: "challenge",
366      randomRatio: 0.9,
367      speedMultiplier: 1.8,
368      minSpeedMultiplier: 1.4,
369      maxSpeedMultiplier: 2.2,
370      spawnMultiplier: 0.68,
371      scoreMultiplier: 3.0,
372      musicTrack: "challenge_edm",
373      musicBpm: 150,
374      clearAccuracy: 85,
375      clearScore: 1100,
376    },
377  ];
378
379  var BEAT_WORDS = ["CODE", "JAVA", "GAME", "APP", "WEB"];
380
381  var BeatProgressStore = {
382    LS_UNLOCKED: "keyboard_beat_unlocked_level",
383    LS_SELECTED: "keyboard_beat_selected_level",
384    _clampLevel: function (n) {
385      var v = parseInt(n, 10);
386      if (isNaN(v) || v < 1) return 1;
387      if (v > 10) return 10;
388      return v;
389    },
390    getUnlockedLevel: function () {
391      try {
392        return this._clampLevel(localStorage.getItem(this.LS_UNLOCKED));
393      } catch (e) {
394        return 1;
395      }
396    },
397    setUnlockedLevel: function (level) {
398      try {
399        localStorage.setItem(this.LS_UNLOCKED, String(this._clampLevel(level)));
400      } catch (e) {
401        /* ignore */
402      }
403    },
404    getSelectedLevel: function () {
405      try {
406        return this._clampLevel(localStorage.getItem(this.LS_SELECTED));
407      } catch (e) {
408        return 1;
409      }
410    },
411    setSelectedLevel: function (level) {
412      try {
413        localStorage.setItem(this.LS_SELECTED, String(this._clampLevel(level)));
414      } catch (e) {
415        /* ignore */
416      }
417    },
418    resetProgress: function () {
419      try {
420        localStorage.removeItem(this.LS_UNLOCKED);
421        localStorage.removeItem(this.LS_SELECTED);
422      } catch (e) {
423        /* ignore */
424      }
425    },
426  };
427
428  function getLevelConfig(levelNum) {
429    var n = BeatProgressStore._clampLevel(levelNum);
430    return KEYBOARD_BEAT_LEVELS[n - 1] || KEYBOARD_BEAT_LEVELS[0];
431  }
432
433  function getBeatGameplayConfig(levelNum) {
434    var n = parseInt(levelNum, 10);
435    if (isNaN(n) || n < 1) n = 1;
436    if (n > BEAT_LEVELS.length) return BEAT_LEVELS[BEAT_LEVELS.length - 1];
437    return BEAT_LEVELS[n - 1];
438  }
439
440  function getCurrentBeatGameplayConfig() {
441    return getBeatGameplayConfig(state.currentLevel);
442  }
443
444  function travelMsFromFallSpeed(fallSpeed) {
445    var fs = fallSpeed > 0 ? fallSpeed : 70;
446    return Math.round(FALL_SPEED_TRAVEL_NUMERATOR / fs);
447  }
448
449  function keysForBeatLevel(levelNum) {
450    var n = BeatProgressStore._clampLevel(levelNum);
451    if (n >= 8) return alphaKeys();
452    return BEAT_KEY_PROGRESSION[n - 1] ? BEAT_KEY_PROGRESSION[n - 1].slice() : BEAT_KEY_PROGRESSION[0].slice();
453  }
454
455  function clampBeatSpeedBias() {
456    var gp = getCurrentBeatGameplayConfig();
457    if (state.speedBias == null || isNaN(state.speedBias)) {
458      state.speedBias = (gp.minSpeed + gp.maxSpeed) / 2;
459    }
460    state.speedBias = Math.max(gp.minSpeed, Math.min(gp.maxSpeed, state.speedBias));
461    var effectiveFall = gp.baseFall * state.speedBias;
462    state.travelMs = travelMsFromFallSpeed(effectiveFall);
463  }
464
465  function nudgeBeatSpeedBias(correct) {
466    var gp = getCurrentBeatGameplayConfig();
467    state.speedBias += correct ? 0.02 : -0.03;
468    clampBeatSpeedBias();
469    beatDebugLog("speedBias", state.speedBias, "travelMs", state.travelMs, "level", state.currentLevel);
470  }
471
472  /** 스테이지·실제 #kbHitLine DOM만 사용 (라벨/내부 추정 좌표로 MISS 판정하지 않음) */
473  function readBeatJudgementRects() {
474    if (!els.laneStage || !els.hitLine) return null;
475    var stageR = els.laneStage.getBoundingClientRect();
476    var lineR = els.hitLine.getBoundingClientRect();
477    if (stageR.height < 8 || lineR.height < 1 || lineR.width < 8) return null;
478    var spawnCenterY = Math.max(14, stageR.height * 0.06);
479    var hitCenterY = lineR.top - stageR.top + lineR.height * 0.5;
480    return {
481      stageTop: stageR.top,
482      stageHeight: stageR.height,
483      spawnCenterY: spawnCenterY,
484      hitCenterY: hitCenterY,
485      lineTop: lineR.top,
486      lineBottom: lineR.bottom,
487    };
488  }
489
490  function updateBeatPlayfieldMetrics() {
491    state.beatPf = readBeatJudgementRects();
492    return state.beatPf;
493  }
494
495  function applyBeatNoteTravelAnchors(note) {
496    var pf = readBeatJudgementRects();
497    if (!pf) return;
498    note.spawnCenterY = pf.spawnCenterY;
499    note.hitCenterY = pf.hitCenterY;
500  }
501
502  /** 스테이지 상대 노트 중심 Y (getBoundingClientRect + translate(-50%,-50%) 반영) */
503  function noteCenterYStageRel(note) {
504    if (!note || !note.el || !els.laneStage) return null;
505    var noteR = note.el.getBoundingClientRect();
506    var stageR = els.laneStage.getBoundingClientRect();
507    if (noteR.height < 1 || stageR.height < 8) return null;
508    return noteR.top + noteR.height * 0.5 - stageR.top;
509  }
510
511  /**
512   * 레이아웃 변경 후: 새 spawn/hit 앵커에 맞춰 progress만 역산해 화면상 중심 유지 (점프 방지)
513   */
514  function rebalanceNoteProgressFromDom(note, pf) {
515    if (!note || !pf) return false;
516    var centerRel = noteCenterYStageRel(note);
517    if (centerRel == null) return false;
518    var spawnY = pf.spawnCenterY;
519    var hitY = pf.hitCenterY;
520    var track = hitY - spawnY;
521    if (Math.abs(track) < 1) {
522      note.spawnCenterY = spawnY;
523      note.hitCenterY = hitY;
524      return false;
525    }
526    var p = (centerRel - spawnY) / track;
527    if (p < 0) p = 0;
528    note.progress = p;
529    note.spawnCenterY = spawnY;
530    note.hitCenterY = hitY;
531    return true;
532  }
533
534  function positionBeatNoteElement(note) {
535    if (!note.el) return;
536    var spawnY = note.spawnCenterY;
537    var hitY = note.hitCenterY;
538    if (spawnY == null || hitY == null) {
539      applyBeatNoteTravelAnchors(note);
540      spawnY = note.spawnCenterY;
541      hitY = note.hitCenterY;
542    }
543    if (spawnY == null || hitY == null) return;
544    var p = note.progress != null ? note.progress : 0;
545    var centerY = spawnY + p * (hitY - spawnY);
546    note.el.style.top = centerY + "px";
547  }
548
549  function logKbLayout(reason) {
550    if (!BEAT_DEBUG) return;
551    var pf = state.beatPf || readBeatJudgementRects();
552    var payload = {
553      reason: reason,
554      stageHeight: pf ? pf.stageHeight : null,
555      lineTop: pf ? pf.lineTop : null,
556      lineBottom: pf ? pf.lineBottom : null,
557      activeNotes: state.notes ? state.notes.length : 0,
558    };
559    console.log("[KB LAYOUT] " + beatStringifySafe(payload));
560  }
561
562  function logKbMissEvent(source, note) {
563    if (!BEAT_DEBUG) return;
564    var payload = {
565      source: source,
566      key: note && note.key,
567      level: state.currentLevel,
568      progress: note && note.progress,
569      spawnCenterY: note && note.spawnCenterY,
570      hitCenterY: note && note.hitCenterY,
571      travelMsForNote: note && note.travelMsForNote,
572      rafId: state.rafId,
573      notesCount: state.notes ? state.notes.length : 0,
574    };
575    if (note && note.el && els.hitLine) {
576      var noteR = note.el.getBoundingClientRect();
577      var lineR = els.hitLine.getBoundingClientRect();
578      payload.noteTop = noteR.top;
579      payload.noteBottom = noteR.bottom;
580      payload.lineTop = lineR.top;
581      payload.lineBottom = lineR.bottom;
582      payload.lineCenter = lineR.top + lineR.height / 2;
583    }
584    console.log("[KB MISS] " + beatStringifySafe(payload));
585  }
586
587  function getNoteLineDistance(note) {
588    if (!note || !note.el || !els.hitLine) return 9999;
589    var noteR = note.el.getBoundingClientRect();
590    var lineR = els.hitLine.getBoundingClientRect();
591    if (noteR.height < 1 || lineR.height < 1) return 9999;
592    var noteCenterY = noteR.top + noteR.height / 2;
593    var lineCenterY = lineR.top + lineR.height / 2;
594    return noteCenterY - lineCenterY;
595  }
596
597  /** HIT 윈도우 — 스테이지 높이 비율 + min/max (PC·모바일 공통) */
598  function getBeatHitWindows() {
599    var stageR = els.laneStage ? els.laneStage.getBoundingClientRect() : null;
600    var h = stageR && stageR.height > 0 ? stageR.height : 700;
601    return {
602      perfect: Math.max(24, Math.min(34, h * 0.035)),
603      good: Math.max(56, Math.min(78, h * 0.085)),
604    };
605  }
606
607  /** HIT 후보: DOM 기준선 중심과의 거리 (progress 보조 없이 최종 판정) */
608  function isNoteInHitWindow(note) {
609    if (!note || !note.el || !els.hitLine) return null;
610    var distance = Math.abs(getNoteLineDistance(note));
611    var windows = getBeatHitWindows();
612    if (distance <= windows.perfect) return "perfect";
613    if (distance <= windows.good) return "good";
614    return null;
615  }
616
617  /** 레이아웃 변경: DOM상 노트 중심 유지하며 앵커·progress 재정렬 */
618  function refreshActiveNoteAnchors(reason) {
619    if (!Array.isArray(state.notes)) return;
620    var pf = updateBeatPlayfieldMetrics();
621    if (!pf) return;
622    for (var i = 0; i < state.notes.length; i++) {
623      var note = state.notes[i];
624      if (!note || note.judged || !note.el) continue;
625      if (!rebalanceNoteProgressFromDom(note, pf)) {
626        applyBeatNoteTravelAnchors(note);
627      }
628      positionBeatNoteElement(note);
629    }
630    logKbLayout(reason || "refreshActiveNoteAnchors");
631  }
632
633  /** MISS: 노트가 기준선을 완전히 지난 뒤만 (닿거나 걸친 상태는 MISS 아님) */
634  function notePassedBeatMissLine(note) {
635    if (!note || !note.el || !els.hitLine) return false;
636    var noteR = note.el.getBoundingClientRect();
637    var lineR = els.hitLine.getBoundingClientRect();
638    if (noteR.height < 1 || lineR.height < 1) return false;
639    return noteR.top > lineR.bottom + MISS_TOLERANCE;
640  }
641
642  function syncBeatPlayfieldLayout(repositionNotes, layoutReason) {
643    updateBeatPlayfieldMetrics();
644    if (repositionNotes) {
645      refreshActiveNoteAnchors(layoutReason || "syncBeatPlayfieldLayout");
646    }
647  }
648
649  function queueBeatPlayfieldLayoutSync(repositionNotes, layoutReason) {
650    var reason = layoutReason || (repositionNotes ? "queueSync:true" : "queueSync:false");
651    var run = function () {
652      syncBeatPlayfieldLayout(repositionNotes, reason);
653    };
654    requestAnimationFrame(function () {
655      run();
656      requestAnimationFrame(function () {
657        run();
658        requestAnimationFrame(run);
659      });
660    });
661  }
662
663  function cancelBeatFrame() {
664    if (state.rafId) {
665      cancelAnimationFrame(state.rafId);
666      state.rafId = 0;
667    }
668  }
669
670  function requestBeatFrame() {
671    cancelBeatFrame();
672    state.lastFramePerf = performance.now();
673    state.rafId = requestAnimationFrame(tickFrame);
674  }
675
676  function scheduleNextBeatFrame() {
677    state.rafId = requestAnimationFrame(tickFrame);
678  }
679
680  /* -------------------------------------------------------------------------- */
681  /* Sound engine (Web Audio API)                                              */
682  /* -------------------------------------------------------------------------- */
683
684  var Sound = {
685    ctx: null,
686    master: null,
687
688    init: function () {
689      var Ctx = window.AudioContext || window.webkitAudioContext;
690      if (!Ctx) return null;
691      if (!this.ctx) {
692        this.ctx = new Ctx();
693        this.master = this.ctx.createGain();
694        this.master.gain.value = 0.45;
695        this.master.connect(this.ctx.destination);
696      }
697      return this.ctx;
698    },
699
700    resume: function () {
701      if (this.ctx && this.ctx.state === "suspended") return this.ctx.resume();
702      return Promise.resolve();
703    },
704
705    _now: function () {
706      return this.ctx ? this.ctx.currentTime : 0;
707    },
708
709    /** 짧은 킥/틱 — 비트용 */
710    beatTick: function () {
711      if (!this.ctx || !this.master) return;
712      var t = this._now();
713      var osc = this.ctx.createOscillator();
714      var g = this.ctx.createGain();
715      var filter = this.ctx.createBiquadFilter();
716      filter.type = "lowpass";
717      filter.frequency.value = 420;
718      osc.type = "triangle";
719      osc.frequency.setValueAtTime(180, t);
720      osc.frequency.exponentialRampToValueAtTime(55, t + 0.06);
721      g.gain.setValueAtTime(0.22, t);
722      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.09);
723      osc.connect(filter);
724      filter.connect(g);
725      g.connect(this.master);
726      osc.start(t);
727      osc.stop(t + 0.1);
728    },
729
730    /** Perfect — 밝고 짧은 팝 */
731    hitPopPerfect: function () {
732      if (!this.ctx || !this.master) return;
733      var t = this._now();
734      var osc = this.ctx.createOscillator();
735      var g = this.ctx.createGain();
736      osc.type = "sine";
737      osc.frequency.setValueAtTime(720, t);
738      osc.frequency.exponentialRampToValueAtTime(1100, t + 0.045);
739      g.gain.setValueAtTime(0.22, t);
740      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.075);
741      osc.connect(g);
742      g.connect(this.master);
743      osc.start(t);
744      osc.stop(t + 0.085);
745    },
746
747    /** Good — 약간 낮고 짧게 */
748    hitPopGood: function () {
749      if (!this.ctx || !this.master) return;
750      var t = this._now();
751      var osc = this.ctx.createOscillator();
752      var g = this.ctx.createGain();
753      osc.type = "triangle";
754      osc.frequency.setValueAtTime(420, t);
755      osc.frequency.exponentialRampToValueAtTime(620, t + 0.05);
756      g.gain.setValueAtTime(0.14, t);
757      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.08);
758      osc.connect(g);
759      g.connect(this.master);
760      osc.start(t);
761      osc.stop(t + 0.09);
762    },
763
764    /** Miss — 낮은 버즈 */
765    missBuzz: function () {
766      if (!this.ctx || !this.master) return;
767      var t = this._now();
768      var osc = this.ctx.createOscillator();
769      var g = this.ctx.createGain();
770      osc.type = "sawtooth";
771      osc.frequency.setValueAtTime(140, t);
772      osc.frequency.linearRampToValueAtTime(90, t + 0.12);
773      g.gain.setValueAtTime(0.12, t);
774      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.14);
775      osc.connect(g);
776      g.connect(this.master);
777      osc.start(t);
778      osc.stop(t + 0.15);
779    },
780  };
781
782  /* -------------------------------------------------------------------------- */
783  /* Game state                                                                 */
784  /* -------------------------------------------------------------------------- */
785
786  var state = {
787    phase: "idle",
788    bpm: 90,
789    keys: [],
790    notes: [],
791    gameStartPerf: 0,
792    rafId: 0,
793    lastFramePerf: 0,
794    beatTimerId: 0,
795    timeLeftId: 0,
796    score: 0,
797    combo: 0,
798    perfect: 0,
799    good: 0,
800    miss: 0,
801    idSeq: 0,
802    maxCombo: 0,
803    endReason: "", // "time_up"
804    lastDurationMs: 0,
805    beatRankingSaveState: "idle", // idle | pending | success | failed
806    lastResultAccNum: 0,
807    /** 랭킹 payload용: 라운드 중 터치 입력이 있으면 "touch", 아니면 "keyboard" */
808    inputMode: "keyboard",
809    selectedLevel: 1,
810    unlockedLevel: 1,
811    /** 현재 선택 레벨의 KEYBOARD_BEAT_LEVELS 항목 참조 */
812    levelConfig: null,
813    /** 캠페인 진행 중 현재 레벨 (1부터) */
814    currentLevel: 1,
815    maxLevelReached: 1,
816    levelCorrectCount: 0,
817    levelMissCount: 0,
818    levelTotalInputCount: 0,
819    levelStartPerf: 0,
820    levelEndPerf: 0,
821    levelNextSpawnPerf: 0,
822    levelClearTimerId: 0,
823    patternCtx: null,
824    spawnSeqIndex: 0,
825    sessionLevelsCleared: 0,
826    /** 노트 낙하 시간 (레벨 fallSpeed 기반) */
827    travelMs: TRAVEL_MS,
828    /** 레벨 내 동적 속도 배율 (minSpeed~maxSpeed) */
829    speedBias: null,
830    speedBiasLevel: 0,
831    /** 판정선·스테이지 DOM 메트릭 (매 프레임/리사이즈 갱신) */
832    beatPf: null,
833    /** 직전 라운드 클리어 여부 (결과 UI) */
834    lastRoundCleared: false,
835    endGameMeta: null,
836  };
837
838  var els = {
839    laneColBgs: [],
840    lanePressTimeouts: [],
841  };
842
843  /**
844   * 모바일: Fullscreen 요청(+실패 시 100dvh 고정 레이아웃)·body 스크롤 잠금
845   * PC   : 플레이 시작 시 플로팅 UI 숨김만 (레이아웃·전체 화면 없음)
846   */
847  var beatChrome = {
848    scrollLocked: false,
849    savedY: 0,
850    fsListenersBound: false,
851    /** 모바일: Start~라운드 종료까지(카운트다운 포함) 몰입 UI 유지 플래그 */
852    immersiveSession: false,
853
854    isMobileKb: function () {
855      try {
856        return window.matchMedia("(max-width: 900px)").matches;
857      } catch (eMq) {
858        return false;
859      }
860    },
861
862    getGameEl: function () {
863      return els.game || document.getElementById("kbGame");
864    },
865
866    /** iPhone/iPad 등: 표준 Fullscreen 미지원·실패해도 fixed 오버레이로 충분 (Android는 선택적 FS 시도 유지) */
867    shouldSkipFullscreenApi: function () {
868      try {
869        var ua = navigator.userAgent || "";
870        if (/iPhone|iPod/.test(ua)) return true;
871        if (/iPad/.test(ua)) return true;
872        if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return true;
873        return false;
874      } catch (eIos) {
875        return false;
876      }
877    },
878
879    fullscreenElementNow: function () {
880      try {
881        return document.fullscreenElement || document.webkitFullscreenElement || null;
882      } catch (e) {
883        return null;
884      }
885    },
886
887    bindFullscreenFallbackListenersOnce: function () {
888      var self = this;
889      if (self.fsListenersBound) return;
890      self.fsListenersBound = true;
891
892      function onFsChange() {
893        try {
894          if (!self.isMobileKb() || !self.immersiveSession) return;
895          var ge = self.getGameEl();
896          var fsNow = self.fullscreenElementNow();
897          if (!ge || fsNow !== ge) self.lockBodyScrollMobile();
898        } catch (eFs) {}
899      }
900
901      document.addEventListener("fullscreenchange", onFsChange);
902      document.addEventListener("webkitfullscreenchange", onFsChange);
903    },
904
905    requestFullscreenSafe: function (el) {
906      if (!el) return null;
907      try {
908        if (typeof el.requestFullscreen === "function") {
909          var req = null;
910          try {
911            req = el.requestFullscreen({ navigationUI: "hide" });
912          } catch (eNav) {
913            try {
914              req = el.requestFullscreen();
915            } catch (eBare) {}
916          }
917          if (req != null && typeof req.catch === "function") {
918            req.catch(function () {});
919          }
920          return req;
921        }
922      } catch (eReq) {}
923
924      try {
925        if (typeof el.webkitRequestFullscreen === "function") {
926          el.webkitRequestFullscreen();
927        }
928      } catch (eWeb) {}
929
930      return null;
931    },
932
933    exitFullscreenSafe: function () {
934      try {
935        if (!this.fullscreenElementNow()) return;
936        if (typeof document.exitFullscreen === "function") document.exitFullscreen();
937        else if (typeof document.webkitExitFullscreen === "function") document.webkitExitFullscreen();
938      } catch (eEx) {}
939    },
940
941    lockBodyScrollMobile: function () {
942      if (!this.isMobileKb() || this.scrollLocked) return;
943      this.scrollLocked = true;
944      this.savedY = window.pageYOffset || document.documentElement.scrollTop || 0;
945      document.body.style.position = "fixed";
946      document.body.style.top = "-" + this.savedY + "px";
947      document.body.style.left = "0";
948      document.body.style.right = "0";
949      document.body.style.width = "100%";
950      document.body.style.overflow = "hidden";
951      document.documentElement.style.overflow = "hidden";
952      try {
953        document.body.style.overscrollBehavior = "none";
954        document.documentElement.style.overscrollBehavior = "none";
955      } catch (eOs) {}
956    },
957
958    unlockBodyScrollSafe: function () {
959      if (!this.scrollLocked) return;
960      this.scrollLocked = false;
961      document.body.style.position = "";
962      document.body.style.top = "";
963      document.body.style.left = "";
964      document.body.style.right = "";
965      document.body.style.width = "";
966      document.body.style.overflow = "";
967      document.documentElement.style.overflow = "";
968      try {
969        document.body.style.overscrollBehavior = "";
970        document.documentElement.style.overscrollBehavior = "";
971      } catch (eOs2) {}
972      window.scrollTo(0, this.savedY);
973    },
974
975    enterMobilePrepFromUserGesture: function () {
976      if (!this.isMobileKb()) return;
977      updateAppHeight();
978      this.immersiveSession = true;
979      document.body.classList.add("kb-mobile-playing");
980      document.body.classList.add("pl-game-focus-mode");
981      var ge = this.getGameEl();
982      if (ge) ge.classList.add("kb-game-immersive");
983      this.bindFullscreenFallbackListenersOnce();
984      this.lockBodyScrollMobile();
985      /* fixed 전체화면이 기본.iOS 등은 여기만으로 충족.Android 등은 선택적으로 FS 시도. */
986      if (!this.shouldSkipFullscreenApi()) {
987        this.requestFullscreenSafe(ge);
988      }
989    },
990
991    retryFullscreenMobile: function () {
992      try {
993        if (!this.isMobileKb() || this.shouldSkipFullscreenApi()) return;
994        this.requestFullscreenSafe(this.getGameEl());
995      } catch (eRy) {}
996    },
997
998    activatePcPlayingChromeOnly: function () {
999      document.body.classList.add("pl-game-focus-mode");
1000    },
1001
1002    teardown: function () {
1003      this.immersiveSession = false;
1004
1005      try {
1006        this.exitFullscreenSafe();
1007      } catch (eT) {}
1008
1009      document.body.classList.remove("kb-mobile-playing");
1010      document.body.classList.remove("pl-game-focus-mode");
1011      try {
1012        var ge = this.getGameEl();
1013        if (ge) ge.classList.remove("kb-game-immersive");
1014      } catch (eR) {}
1015
1016      try {
1017        this.unlockBodyScrollSafe();
1018      } catch (eU) {}
1019    },
1020  };
1021
1022  function currentTravelMs() {
1023    return state.travelMs > 0 ? state.travelMs : TRAVEL_MS;
1024  }
1025
1026  function applyTravelMsFromGameplayConfig() {
1027    var gp = getCurrentBeatGameplayConfig();
1028    if (state.speedBiasLevel !== state.currentLevel || state.speedBias == null) {
1029      state.speedBias = (gp.minSpeed + gp.maxSpeed) / 2;
1030      state.speedBiasLevel = state.currentLevel;
1031    }
1032    clampBeatSpeedBias();
1033  }
1034
1035  function getSelectedLevelConfig() {
1036    return getLevelConfig(state.selectedLevel);
1037  }
1038
1039  function resolveBeatKeysForLevel(levelNum) {
1040    var cfg = getLevelConfig(levelNum);
1041    state.levelConfig = cfg;
1042    state.keys = normalizeBeatKeysList(keysForBeatLevel(levelNum));
1043    beatDebugLog("keyPool level", levelNum, state.keys);
1044  }
1045
1046  function resolveBeatKeys() {
1047    resolveBeatKeysForLevel(state.selectedLevel);
1048  }
1049
1050  function getLevelAccuracy() {
1051    if (state.levelTotalInputCount <= 0) return 0;
1052    return Math.round((state.levelCorrectCount / state.levelTotalInputCount) * 100);
1053  }
1054
1055  function resetLevelCounters() {
1056    state.levelCorrectCount = 0;
1057    state.levelMissCount = 0;
1058    state.levelTotalInputCount = 0;
1059  }
1060
1061  function recordLevelInput(correct) {
1062    state.levelTotalInputCount += 1;
1063    if (correct) state.levelCorrectCount += 1;
1064    else state.levelMissCount += 1;
1065    if (state.phase === "playing") nudgeBeatSpeedBias(correct);
1066  }
1067
1068  /** 레벨·스케줄·판정에 쓰는 키 배열 — 라틴 알파벳은 대문자로 통일 */
1069  function normalizeBeatKeysList(keys) {
1070    var out = [];
1071    var seen = {};
1072    if (!keys || !keys.length) return out;
1073    for (var i = 0; i < keys.length; i++) {
1074      var slot = canonicalBeatKeyFromAllow(keys[i], keys);
1075      if (!slot) slot = normalizeBeatKeyCompareUnit(keys[i]);
1076      if (!slot || seen[slot]) continue;
1077      seen[slot] = true;
1078      out.push(slot);
1079    }
1080    return out;
1081  }
1082
1083  /** allow 배열 안에서 raw와 동일 키(대소문자 무시)의 표준 문자열 */
1084  function canonicalBeatKeyFromAllow(raw, allow) {
1085    var wantNorm = normalizeBeatKeyCompareUnit(raw);
1086    if (!wantNorm || !allow || !allow.length) return "";
1087    for (var i = 0; i < allow.length; i++) {
1088      if (normalizeBeatKeyCompareUnit(allow[i]) === wantNorm) {
1089        return normalizeBeatKeyCompareUnit(allow[i]);
1090      }
1091    }
1092    return "";
1093  }
1094
1095  function difficultyScoreMultiplier() {
1096    var slug = readDifficultySlug();
1097    return DIFF_SCORE_MULT[slug] != null ? DIFF_SCORE_MULT[slug] : DIFF_SCORE_MULT.easy;
1098  }
1099
1100  function levelScoreMultiplier() {
1101    return state.levelConfig && state.levelConfig.scoreMultiplier != null ? state.levelConfig.scoreMultiplier : 1;
1102  }
1103
1104  function pickRandomKeyFrom(arr) {
1105    var a = arr && arr.length ? arr : state.keys;
1106    if (!a || !a.length) return "A";
1107    return a[Math.floor(Math.random() * a.length)];
1108  }
1109
1110  /** 스케줄 노트용: 반드시 allow 배열 안의 키만 반환 (대소문자 무시) */
1111  function ensureScheduleKey(key, allow) {
1112    var canon = canonicalBeatKeyFromAllow(key, allow);
1113    if (canon) return canon;
1114    return pickRandomKeyFrom(allow);
1115  }
1116
1117  function filterWordsForLevelKeys(words, keySet) {
1118    var out = [];
1119    if (!words || !words.length || !keySet || !keySet.length) return out;
1120    for (var wi = 0; wi < words.length; wi++) {
1121      var w = words[wi];
1122      if (!w || !w.length) continue;
1123      var ok = true;
1124      for (var ci = 0; ci < w.length; ci++) {
1125        if (keySet.indexOf(w.charAt(ci)) < 0) {
1126          ok = false;
1127          break;
1128        }
1129      }
1130      if (ok) out.push(w);
1131    }
1132    return out;
1133  }
1134
1135  function createPatternContext(levelConfig) {
1136    var keys = normalizeBeatKeysList(levelConfig.keys);
1137    return {
1138      keys: keys,
1139      seqPos: 0,
1140      wordIndex: 0,
1141      wordCharIndex: 0,
1142      words: filterWordsForLevelKeys(BEAT_WORDS.slice(), keys),
1143      challengeMode: 0,
1144      _repeatSeq: null,
1145      _altSeq: null,
1146      _homeSeq: null,
1147    };
1148  }
1149
1150  function pickPatternKey(levelConfig, ctx, index) {
1151    var keys = ctx.keys;
1152    var pattern = levelConfig.pattern;
1153    var allow = levelConfig.keys;
1154    if (!keys.length) return ensureScheduleKey("A", allow);
1155
1156    var raw = "";
1157
1158    if (pattern === "repeat") {
1159      if (!ctx._repeatSeq) {
1160        ctx._repeatSeq = [];
1161        for (var i = 0; i < keys.length; i++) {
1162          ctx._repeatSeq.push(keys[i], keys[i]);
1163        }
1164      }
1165      raw = ctx._repeatSeq[ctx.seqPos % ctx._repeatSeq.length];
1166      ctx.seqPos += 1;
1167      return ensureScheduleKey(raw, allow);
1168    }
1169
1170    if (pattern === "basic") {
1171      raw = keys[index % keys.length];
1172      ctx.seqPos += 1;
1173      return ensureScheduleKey(raw, allow);
1174    }
1175
1176    if (pattern === "alternate") {
1177      if (!ctx._altSeq) {
1178        var altFull = ["A", "J", "S", "K", "D", "L", "F", ";"];
1179        ctx._altSeq = [];
1180        for (var ai = 0; ai < altFull.length; ai++) {
1181          if (keys.indexOf(altFull[ai]) >= 0) ctx._altSeq.push(altFull[ai]);
1182        }
1183      }
1184      if (!ctx._altSeq.length) return pickRandomKeyFrom(allow);
1185      raw = ctx._altSeq[ctx.seqPos % ctx._altSeq.length];
1186      ctx.seqPos += 1;
1187      return ensureScheduleKey(raw, allow);
1188    }
1189
1190    if (pattern === "random_home") {
1191      if (!ctx._homeSeq) {
1192        var homeFull = ["A", "S", "D", "F", "J", "K", "L", ";"];
1193        ctx._homeSeq = [];
1194        for (var hi = 0; hi < homeFull.length; hi++) {
1195          if (keys.indexOf(homeFull[hi]) >= 0) ctx._homeSeq.push(homeFull[hi]);
1196        }
1197      }
1198      if (!ctx._homeSeq.length) return pickRandomKeyFrom(allow);
1199      raw = ctx._homeSeq[ctx.seqPos % ctx._homeSeq.length];
1200      ctx.seqPos += 1;
1201      return ensureScheduleKey(raw, allow);
1202    }
1203
1204    if (pattern === "top_row" || pattern === "bottom_row") {
1205      raw = keys[ctx.seqPos % keys.length];
1206      ctx.seqPos += 1;
1207      return ensureScheduleKey(raw, allow);
1208    }
1209
1210    if (pattern === "all_random") {
1211      raw = keys[ctx.seqPos % keys.length];
1212      ctx.seqPos += 1;
1213      return ensureScheduleKey(raw, allow);
1214    }
1215
1216    if (pattern === "word") {
1217      if (!ctx.words.length) return pickRandomKeyFrom(allow);
1218      var w = ctx.words[ctx.wordIndex % ctx.words.length];
1219      raw = w.charAt(ctx.wordCharIndex);
1220      ctx.wordCharIndex += 1;
1221      if (ctx.wordCharIndex >= w.length) {
1222        ctx.wordCharIndex = 0;
1223        ctx.wordIndex += 1;
1224      }
1225      return ensureScheduleKey(raw, allow);
1226    }
1227
1228    if (pattern === "challenge") {
1229      var m = index % 7;
1230      if (m === 0 || m === 1) {
1231        return pickRandomKeyFrom(allow);
1232      }
1233      if (m >= 2 && m <= 4) {
1234        if (!ctx.words.length) return pickRandomKeyFrom(allow);
1235        var w2 = ctx.words[ctx.wordIndex % ctx.words.length];
1236        raw = w2.charAt(ctx.wordCharIndex);
1237        ctx.wordCharIndex += 1;
1238        if (ctx.wordCharIndex >= w2.length) {
1239          ctx.wordCharIndex = 0;
1240          ctx.wordIndex += 1;
1241        }
1242        return ensureScheduleKey(raw, allow);
1243      }
1244      raw = keys[ctx.seqPos % keys.length];
1245      ctx.seqPos += 1;
1246      return ensureScheduleKey(raw, allow);
1247    }
1248
1249    raw = keys[ctx.seqPos++ % keys.length];
1250    return ensureScheduleKey(raw, allow);
1251  }
1252
1253  function laneIndexForKey(key) {
1254    var canon = canonicalBeatLaneKey(key);
1255    if (canon) return state.keys.indexOf(canon);
1256    return -1;
1257  }
1258
1259  function resetRoundStats() {
1260    state.score = 0;
1261    state.currentLevel = 1;
1262    state.maxLevelReached = 1;
1263    state.sessionLevelsCleared = 0;
1264    resetLevelCounters();
1265    state.combo = 0;
1266    state.perfect = 0;
1267    state.good = 0;
1268    state.miss = 0;
1269    state.maxCombo = 0;
1270    state.endReason = "";
1271    state.beatRankingSaveState = "idle";
1272    state.inputMode = "keyboard";
1273  }
1274
1275  function resetState() {
1276    state.phase = "idle";
1277    resetRoundStats();
1278    cancelBeatFrame();
1279    if (state.beatTimerId) {
1280      clearInterval(state.beatTimerId);
1281      state.beatTimerId = 0;
1282    }
1283    if (state.levelClearTimerId) {
1284      clearTimeout(state.levelClearTimerId);
1285      state.levelClearTimerId = 0;
1286    }
1287    if (state.timeLeftId) {
1288      clearInterval(state.timeLeftId);
1289      state.timeLeftId = 0;
1290    }
1291    state.patternCtx = null;
1292    state.spawnSeqIndex = 0;
1293    state.lastFramePerf = 0;
1294    hideLevelClearOverlay();
1295    clearPlayfieldNotes();
1296    if (els.boardFx) {
1297      els.boardFx.querySelectorAll(".kb-center-judge, .kb-hit-burst").forEach(function (n) { n.remove(); });
1298    }
1299    if (els.lanePressTimeouts && els.lanePressTimeouts.length) {
1300      els.lanePressTimeouts.forEach(function (tid) {
1301        if (tid) clearTimeout(tid);
1302      });
1303      els.lanePressTimeouts = [];
1304    }
1305  }
1306
1307  /* -------------------------------------------------------------------------- */
1308  /* Scoring                                                                    */
1309  /* -------------------------------------------------------------------------- */
1310
1311  function updatePlayHud() {
1312    if (els.playScore) els.playScore.textContent = String(state.score);
1313    if (els.playComboHud) els.playComboHud.textContent = String(state.combo);
1314    var gp = getCurrentBeatGameplayConfig();
1315    var acc = getLevelAccuracy();
1316    if (els.kbPlayAccuracy) els.kbPlayAccuracy.textContent = acc + "%";
1317    if (els.kbPlayTargetAccuracy) els.kbPlayTargetAccuracy.textContent = gp.requiredAccuracy + "%";
1318    updatePlayHudLevelBrief();
1319    refreshComboDock();
1320  }
1321
1322  /** 노트 MISS 또는 잘못된 키 MISS — 점수 감점·속도 하향, 라운드는 시간까지 유지 */
1323  function penalizeMiss(laneIdx, meta) {
1324    meta = meta || {};
1325    if (BEAT_DEBUG && meta.source === "wrong_key") {
1326      console.log("[KB MISS] " + beatStringifySafe({
1327        source: "wrong_key",
1328        laneIdx: laneIdx,
1329        level: state.currentLevel,
1330        rafId: state.rafId,
1331        notesCount: state.notes ? state.notes.length : 0,
1332      }));
1333    }
1334    state.miss += 1;
1335    recordLevelInput(false);
1336    state.combo = 0;
1337    var slug = readDifficultySlug();
1338    var dm = DIFF_MISS_SCORE_MULT[slug] != null ? DIFF_MISS_SCORE_MULT[slug] : DIFF_MISS_SCORE_MULT.easy;
1339    var penalty = Math.round(MISS_SCORE_PENALTY_BASE * dm);
1340    state.score = Math.max(0, state.score - penalty);
1341    Sound.missBuzz();
1342    boardFlashMiss();
1343    var li = typeof laneIdx === "number" && laneIdx >= 0 ? laneIdx : 0;
1344    showCenterJudgment("miss");
1345    spawnHitBurst(li, "miss");
1346    updatePlayHud();
1347    return false;
1348  }
1349
1350  function setTimeLeft(sec) {
1351    if (els.playTime) els.playTime.textContent = String(Math.max(0, Math.ceil(sec)));
1352  }
1353
1354  function refreshComboDock() {
1355    if (!els.comboDock || !els.comboBig) return;
1356    if (state.combo >= 2) {
1357      els.comboDock.classList.remove("is-hidden");
1358      els.comboBig.textContent = String(state.combo);
1359      els.comboDock.classList.remove("is-pop");
1360      void els.comboDock.offsetWidth;
1361      els.comboDock.classList.add("is-pop");
1362    } else {
1363      els.comboDock.classList.add("is-hidden");
1364    }
1365  }
1366
1367  function applyJudgment(note, j, meta) {
1368    meta = meta || {};
1369    var laneIdx = typeof note.laneIndex === "number" ? note.laneIndex : 0;
1370    var scoreMult = difficultyScoreMultiplier() * levelScoreMultiplier();
1371    if (j === "perfect") {
1372      state.perfect += 1;
1373      recordLevelInput(true);
1374      state.combo += 1;
1375      state.score += Math.round((SCORE_PERFECT + state.combo * COMBO_BONUS) * scoreMult);
1376      state.maxCombo = Math.max(state.maxCombo, state.combo);
1377      Sound.hitPopPerfect();
1378      note.judged = true;
1379      showCenterJudgment(j);
1380      spawnHitBurst(laneIdx, j);
1381      if (note.el && note.el.parentNode) note.el.remove();
1382      updatePlayHud();
1383    } else if (j === "good") {
1384      state.good += 1;
1385      recordLevelInput(true);
1386      state.combo += 1;
1387      state.score += Math.round((SCORE_GOOD + Math.floor(state.combo * COMBO_BONUS * 0.5)) * scoreMult);
1388      state.maxCombo = Math.max(state.maxCombo, state.combo);
1389      Sound.hitPopGood();
1390      note.judged = true;
1391      showCenterJudgment(j);
1392      spawnHitBurst(laneIdx, j);
1393      if (note.el && note.el.parentNode) note.el.remove();
1394      updatePlayHud();
1395    } else {
1396      note.judged = true;
1397      if (note.el && note.el.parentNode) note.el.remove();
1398      penalizeMiss(laneIdx, { source: meta.source || "pass" });
1399    }
1400  }
1401
1402  function ratioForNote(note, nowPerf) {
1403    if (note.progress != null) return note.progress;
1404    var tr =
1405      note.travelMsForNote != null && note.travelMsForNote > 0 ? note.travelMsForNote : currentTravelMs();
1406    var spawn = note.spawnPerf != null ? note.spawnPerf : note.hitTime - tr;
1407    return (nowPerf - spawn) / tr;
1408  }
1409
1410  /* -------------------------------------------------------------------------- */
1411  /* Lanes (DOM)                                                                */
1412  /* -------------------------------------------------------------------------- */
1413
1414  function relayoutLaneGrid() {
1415    if (!els.lanesBg || !state.keys.length) return;
1416    var n = state.keys.length;
1417    var mobile = window.matchMedia("(max-width: 900px)").matches;
1418    var tpl = mobile
1419      ? "repeat(" + n + ", minmax(38px, 1fr))"
1420      : "repeat(" + n + ", minmax(0, 1fr))";
1421    els.lanesBg.style.gridTemplateColumns = tpl;
1422    if (els.laneLabels) els.laneLabels.style.gridTemplateColumns = tpl;
1423    refreshActiveNoteAnchors("relayoutLaneGrid");
1424  }
1425
1426  function buildLaneDom() {
1427    if (!els.lanesBg || !els.laneLabels || !els.notesLayer || !els.lane) return;
1428    var keys = state.keys;
1429    var n = keys.length;
1430    els.lane.classList.add("kb-lane--multi");
1431    els.lane.style.setProperty("--kb-n-lanes", String(n));
1432    els.lanesBg.innerHTML = "";
1433    els.laneLabels.innerHTML = "";
1434    els.laneColBgs = [];
1435    els.lanePressTimeouts = [];
1436    for (var i = 0; i < n; i++) {
1437      var bg = document.createElement("div");
1438      bg.className = "kb-lane-col-bg";
1439      bg.dataset.key = keys[i];
1440      bg.dataset.laneIndex = String(i);
1441      els.lanesBg.appendChild(bg);
1442      els.laneColBgs.push(bg);
1443      var lab = document.createElement("div");
1444      lab.className = "kb-lane__label-cell";
1445      lab.textContent = keys[i];
1446      lab.dataset.key = keys[i];
1447      els.laneLabels.appendChild(lab);
1448    }
1449    relayoutLaneGrid();
1450    if (state.phase === "playing") {
1451      refreshActiveNoteAnchors("buildLaneDom");
1452      queueBeatPlayfieldLayoutSync(true, "buildLaneDom");
1453    }
1454  }
1455
1456  function setLanePressedVisual(idx, on) {
1457    if (idx < 0 || !els.laneColBgs || !els.laneColBgs[idx]) return;
1458    els.laneColBgs[idx].classList.toggle("is-pressed", !!on);
1459    var label = els.laneLabels && els.laneLabels.children[idx];
1460    if (label) label.classList.toggle("is-pressed", !!on);
1461  }
1462
1463  function pulseLanePressed(char) {
1464    var idx = state.keys.indexOf(char);
1465    if (idx < 0) return;
1466    setLanePressedVisual(idx, true);
1467    if (!els.lanePressTimeouts) els.lanePressTimeouts = [];
1468    if (els.lanePressTimeouts[idx]) clearTimeout(els.lanePressTimeouts[idx]);
1469    els.lanePressTimeouts[idx] = setTimeout(function () {
1470      setLanePressedVisual(idx, false);
1471    }, 140);
1472  }
1473
1474  function flashLaneWarn(char) {
1475    var idx = state.keys.indexOf(char);
1476    if (idx < 0 || !els.laneColBgs || !els.laneColBgs[idx]) return;
1477    var el = els.laneColBgs[idx];
1478    el.classList.add("is-warn");
1479    setTimeout(function () {
1480      el.classList.remove("is-warn");
1481    }, 200);
1482    boardShakeWrong();
1483  }
1484
1485  function boardFlashMiss() {
1486    if (!els.board) return;
1487    els.board.classList.remove("kb-board--miss-shake");
1488    void els.board.offsetWidth;
1489    els.board.classList.add("kb-board--miss-shake");
1490    setTimeout(function () {
1491      if (els.board) els.board.classList.remove("kb-board--miss-shake");
1492    }, 480);
1493  }
1494
1495  function boardShakeWrong() {
1496    if (!els.board) return;
1497    els.board.classList.add("kb-board--shake-soft");
1498    setTimeout(function () {
1499      if (els.board) els.board.classList.remove("kb-board--shake-soft");
1500    }, 220);
1501  }
1502
1503  function showCenterJudgment(j) {
1504    if (!els.boardFx) return;
1505    var el = document.createElement("div");
1506    el.className = "kb-center-judge kb-center-judge--" + j;
1507    el.setAttribute("role", "status");
1508    el.textContent = j === "perfect" ? "PERFECT" : j === "good" ? "GOOD" : "MISS";
1509    els.boardFx.appendChild(el);
1510    requestAnimationFrame(function () {
1511      el.classList.add("is-on");
1512    });
1513    setTimeout(function () {
1514      el.classList.add("is-out");
1515      setTimeout(function () {
1516        if (el.parentNode) el.remove();
1517      }, 480);
1518    }, 420);
1519  }
1520
1521  function spawnHitBurst(laneIndex, j) {
1522    if (!els.boardFx || !state.keys.length) return;
1523    var el = document.createElement("div");
1524    el.className = "kb-hit-burst kb-hit-burst--" + j;
1525    var leftPct = ((laneIndex + 0.5) / state.keys.length) * 100;
1526    el.style.left = leftPct + "%";
1527    els.boardFx.appendChild(el);
1528    requestAnimationFrame(function () {
1529      el.classList.add("is-on");
1530    });
1531    setTimeout(function () {
1532      if (el.parentNode) el.remove();
1533    }, j === "perfect" ? 650 : j === "good" ? 520 : 480);
1534  }
1535
1536  /* -------------------------------------------------------------------------- */
1537  /* Note spawn & DOM                                                           */
1538  /* -------------------------------------------------------------------------- */
1539
1540  function pickNextLevelNoteKey() {
1541    var lc = state.levelConfig || getLevelConfig(state.currentLevel);
1542    if (!state.patternCtx) state.patternCtx = createPatternContext(lc);
1543    var randRatio = lc.randomRatio != null ? lc.randomRatio : 0.5;
1544    var idx = state.spawnSeqIndex;
1545    state.spawnSeqIndex += 1;
1546    var k =
1547      Math.random() < randRatio ? pickRandomKeyFrom(lc.keys) : pickPatternKey(lc, state.patternCtx, idx);
1548    return ensureScheduleKey(k, state.keys);
1549  }
1550
1551  function spawnNoteAt(nowPerf, key) {
1552    var noteKey = canonicalBeatLaneKey(key) || ensureScheduleKey(key, state.keys);
1553    var travel = currentTravelMs();
1554    state.idSeq += 1;
1555    var id = "kbn" + state.idSeq;
1556    var laneIndex = laneIndexForKey(noteKey);
1557    if (laneIndex < 0) laneIndex = 0;
1558    var nKeys = state.keys.length || 1;
1559    var leftPct = ((laneIndex + 0.5) / nKeys) * 100;
1560    var el = document.createElement("div");
1561    el.className = "kb-note";
1562    el.dataset.id = id;
1563    el.dataset.laneIndex = String(laneIndex);
1564    el.style.left = leftPct + "%";
1565    el.innerHTML = '<span class="kb-note__char"></span>';
1566    var ch = el.querySelector(".kb-note__char");
1567    ch.textContent = noteKey;
1568    if (els.notesLayer) els.notesLayer.appendChild(el);
1569    else if (els.lane && els.hitLine) els.lane.insertBefore(el, els.hitLine);
1570    var noteObj = {
1571      id: id,
1572      key: noteKey,
1573      hitTime: nowPerf + travel,
1574      el: el,
1575      judged: false,
1576      laneIndex: laneIndex,
1577      travelMsForNote: travel,
1578      spawnPerf: nowPerf,
1579      progress: 0,
1580      spawnCenterY: null,
1581      hitCenterY: null,
1582    };
1583    applyBeatNoteTravelAnchors(noteObj);
1584    state.notes.push(noteObj);
1585    positionBeatNoteElement(noteObj);
1586  }
1587
1588  function tryLevelSpawn(nowPerf) {
1589    var gp = getCurrentBeatGameplayConfig();
1590    var interval = gp.spawnInterval > 0 ? gp.spawnInterval : 1600;
1591    while (nowPerf >= state.levelNextSpawnPerf && state.phase === "playing") {
1592      spawnNoteAt(nowPerf, pickNextLevelNoteKey());
1593      state.levelNextSpawnPerf += interval;
1594      if (state.levelNextSpawnPerf > state.levelEndPerf) break;
1595    }
1596  }
1597
1598  function advanceNotes(deltaMs) {
1599    var dt = deltaMs > 0 ? deltaMs : 0;
1600    for (var i = 0; i < state.notes.length; i++) {
1601      var n = state.notes[i];
1602      if (n.judged || !n.el) continue;
1603      var tr = n.travelMsForNote > 0 ? n.travelMsForNote : currentTravelMs();
1604      n.progress = (n.progress != null ? n.progress : 0) + dt / tr;
1605      positionBeatNoteElement(n);
1606    }
1607  }
1608
1609  function clearPlayfieldNotes() {
1610    if (els.notesLayer) els.notesLayer.querySelectorAll(".kb-note").forEach(function (x) { x.remove(); });
1611    else if (els.lane) els.lane.querySelectorAll(".kb-note").forEach(function (x) { x.remove(); });
1612    state.notes = [];
1613  }
1614
1615  function cullMissed(nowPerf) {
1616    for (var i = state.notes.length - 1; i >= 0; i--) {
1617      var n = state.notes[i];
1618      if (n.judged) {
1619        state.notes.splice(i, 1);
1620        continue;
1621      }
1622      if (notePassedBeatMissLine(n)) {
1623        logKbMissEvent("pass", n);
1624        applyJudgment(n, "miss", { source: "pass" });
1625        state.notes.splice(i, 1);
1626        if (state.phase !== "playing") return;
1627      }
1628    }
1629  }
1630
1631  /* -------------------------------------------------------------------------- */
1632  /* Input                                                                      */
1633  /* -------------------------------------------------------------------------- */
1634
1635  function eventToChar(ev) {
1636    if (!ev || ev.ctrlKey || ev.metaKey || ev.altKey) return "";
1637    var code = String(ev.code || "");
1638    if (code.indexOf("Key") === 0 && code.length === 4) {
1639      return code.slice(3).toUpperCase();
1640    }
1641    if (code === "Semicolon") return ";";
1642    if (code === "Comma") return ",";
1643    if (code === "Period") return ".";
1644    if (code === "Slash") return "/";
1645    if (code.indexOf("Digit") === 0 && code.length === 6) {
1646      return code.slice(5);
1647    }
1648    var k = String(ev.key || "");
1649    if (k === " ") return "";
1650    if (k.length !== 1) return "";
1651    return normalizeBeatKeyCompareUnit(k);
1652  }
1653
1654  /** 단일 문자: 라틴 알파벳만 대문자로 통일해 비교. 숫자·세미콜론·기타 기호는 그대로 유지 */
1655  function normalizeBeatKeyCompareUnit(ch) {
1656    if (ch == null || ch === "") return "";
1657    var s = String(ch);
1658    if (s.length !== 1) return s;
1659    var c = s.charAt(0);
1660    if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z")) return c.toUpperCase();
1661    return s;
1662  }
1663
1664  function beatKeysEquivalentForJudgment(noteKey, inputKey) {
1665    return normalizeBeatKeyCompareUnit(noteKey) === normalizeBeatKeyCompareUnit(inputKey);
1666  }
1667
1668  /** 키보드 입력(또는 터치 라벨) → state.keys 배열과 동일한 문자열(레인 인덱스·스폰 노트 표기와 동일) */
1669  function canonicalBeatLaneKey(raw) {
1670    return canonicalBeatKeyFromAllow(raw, state.keys);
1671  }
1672
1673  function handlePlayerInput(char, meta) {
1674    if (state.phase !== "playing") return;
1675    var chRaw = char != null ? String(char) : "";
1676    var canon = canonicalBeatLaneKey(chRaw);
1677    if (!canon) return;
1678    if (meta && meta.source === "touch") {
1679      state.inputMode = "touch";
1680    }
1681    pulseLanePressed(canon);
1682    tryHitWithChar(canon);
1683  }
1684
1685  function tryHitWithChar(char) {
1686    if (state.phase !== "playing") return;
1687    var want = char;
1688    var best = null;
1689    var bestDist = 9999;
1690    var bestJ = null;
1691    for (var i = 0; i < state.notes.length; i++) {
1692      var n = state.notes[i];
1693      if (n.judged || !n.el || !beatKeysEquivalentForJudgment(n.key, want)) continue;
1694      var hitJ = isNoteInHitWindow(n);
1695      if (!hitJ) continue;
1696      var dist = Math.abs(getNoteLineDistance(n));
1697      if (dist < bestDist) {
1698        bestDist = dist;
1699        best = n;
1700        bestJ = hitJ;
1701      }
1702    }
1703    if (!best || !bestJ) {
1704      var laneIx = laneIndexForKey(want);
1705      if (laneIx >= 0) {
1706        flashLaneWarn(want);
1707        penalizeMiss(laneIx, { source: "wrong_key" });
1708      }
1709      return;
1710    }
1711    applyJudgment(best, bestJ);
1712    for (var j2 = state.notes.length - 1; j2 >= 0; j2--) {
1713      if (state.notes[j2] === best) state.notes.splice(j2, 1);
1714    }
1715  }
1716
1717  function onKeyDown(ev) {
1718    if (state.phase !== "playing") return;
1719    if (ev.repeat) return;
1720    var cRaw = eventToChar(ev);
1721    if (!cRaw) return;
1722    if (!canonicalBeatLaneKey(cRaw)) return;
1723    ev.preventDefault();
1724    handlePlayerInput(cRaw, { source: "keyboard" });
1725  }
1726
1727  /* -------------------------------------------------------------------------- */
1728  /* Animation loop                                                             */
1729  /* -------------------------------------------------------------------------- */
1730
1731  function showLevelClearOverlay(accuracy, requiredAccuracy, isFinalStage) {
1732    if (!els.kbLevelTransition) return;
1733    var nextLv = state.currentLevel + 1;
1734    if (els.kbLevelTransitionTitle) {
1735      els.kbLevelTransitionTitle.textContent = isFinalStage
1736        ? "LEVEL " + state.currentLevel + " CLEAR!"
1737        : "LEVEL " + state.currentLevel + " CLEAR!";
1738    }
1739    if (els.kbLevelTransitionSub) {
1740      var sub = "정확도 " + accuracy + "% / 목표 " + requiredAccuracy + "%";
1741      if (isFinalStage) sub += "\n최종 스테이지 돌파!";
1742      sub += "\nLEVEL " + nextLv + " 시작";
1743      els.kbLevelTransitionSub.textContent = sub;
1744    }
1745    els.kbLevelTransition.classList.remove("is-hidden");
1746  }
1747
1748  function hideLevelClearOverlay() {
1749    if (els.kbLevelTransition) els.kbLevelTransition.classList.add("is-hidden");
1750  }
1751
1752  function startCampaignLevel(levelNum) {
1753    if (!warnMissingPlayfield("startCampaignLevel")) {
1754      showSetup();
1755      return;
1756    }
1757    state.currentLevel = levelNum;
1758    state.maxLevelReached = Math.max(state.maxLevelReached, levelNum);
1759    resetLevelCounters();
1760    clearPlayfieldNotes();
1761    if (els.boardFx) {
1762      els.boardFx.querySelectorAll(".kb-center-judge, .kb-hit-burst").forEach(function (x) { x.remove(); });
1763    }
1764    resolveBeatKeysForLevel(levelNum);
1765    applyTravelMsFromGameplayConfig();
1766    state.patternCtx = createPatternContext(state.levelConfig);
1767    state.spawnSeqIndex = 0;
1768    buildLaneDom();
1769    var gp = getCurrentBeatGameplayConfig();
1770    var now = performance.now();
1771    state.levelStartPerf = now;
1772    state.levelEndPerf = now + LEVEL_DURATION * 1000;
1773    state.levelNextSpawnPerf = now + Math.min(900, gp.spawnInterval * 0.55);
1774    state.lastFramePerf = now;
1775    state.phase = "playing";
1776    setTimeLeft(LEVEL_DURATION);
1777    updatePlayHud();
1778    refreshTouchRow();
1779    syncBeatPlayfieldLayout(false, "startCampaignLevel");
1780    queueBeatPlayfieldLayoutSync(true, "startCampaignLevel");
1781    beatDebugLog("level start", {
1782      playAreaHeight: state.beatPf ? state.beatPf.stageHeight : null,
1783      judgementLineTop: state.beatPf ? state.beatPf.lineTop : null,
1784      judgementLineBottom: state.beatPf ? state.beatPf.lineBottom : null,
1785      missLineY: state.beatPf ? state.beatPf.hitCenterY : null,
1786      level: state.currentLevel,
1787      speed: state.speedBias,
1788      keyPool: state.keys.slice(),
1789      travelMs: state.travelMs,
1790    });
1791  }
1792
1793  function finishLevel() {
1794    if (state.phase !== "playing") return;
1795    state.phase = "level_transition";
1796    var accuracy = getLevelAccuracy();
1797    var gp = getCurrentBeatGameplayConfig();
1798    var required = gp.requiredAccuracy;
1799
1800    if (accuracy >= required) {
1801      clearPlayfieldNotes();
1802      state.sessionLevelsCleared += 1;
1803      if (state.currentLevel >= state.unlockedLevel && state.currentLevel < 10) {
1804        state.unlockedLevel = Math.max(state.unlockedLevel, state.currentLevel + 1);
1805        BeatProgressStore.setUnlockedLevel(state.unlockedLevel);
1806      }
1807      var isFinalStage = state.currentLevel >= 10;
1808      showLevelClearOverlay(accuracy, required, isFinalStage);
1809      if (state.levelClearTimerId) clearTimeout(state.levelClearTimerId);
1810      state.levelClearTimerId = setTimeout(function () {
1811        state.levelClearTimerId = 0;
1812        hideLevelClearOverlay();
1813        startNextLevel();
1814      }, 1500);
1815    } else {
1816      endGame({
1817        reason: "accuracy_failed",
1818        accuracy: accuracy,
1819        requiredAccuracy: required,
1820        level: state.currentLevel,
1821        score: state.score,
1822      });
1823    }
1824  }
1825
1826  function startNextLevel() {
1827    startCampaignLevel(state.currentLevel + 1);
1828    queueBeatPlayfieldLayoutSync(true, "startNextLevel");
1829    requestBeatFrame();
1830  }
1831
1832  function tickFrame(now) {
1833    if (state.phase !== "playing") return;
1834    if (!hasMinimalPlayfieldDom()) {
1835      console.warn("[Keyboard Beat] tickFrame aborted: missing playfield DOM");
1836      showSetup();
1837      return;
1838    }
1839    var ts = typeof now === "number" ? now : performance.now();
1840    if (!state.lastFramePerf) state.lastFramePerf = ts;
1841    var deltaMs = Math.min(FRAME_DELTA_MS_CAP, Math.max(0, ts - state.lastFramePerf));
1842    state.lastFramePerf = ts;
1843
1844    updateBeatPlayfieldMetrics();
1845
1846    tryLevelSpawn(ts);
1847    advanceNotes(deltaMs);
1848    cullMissed(ts);
1849    if (state.phase !== "playing") return;
1850
1851    if (ts >= state.levelEndPerf) {
1852      finishLevel();
1853      return;
1854    }
1855
1856    setTimeLeft((state.levelEndPerf - ts) / 1000);
1857    updatePlayHud();
1858    scheduleNextBeatFrame();
1859  }
1860
1861  /* -------------------------------------------------------------------------- */
1862  /* Game flow                                                                  */
1863  /* -------------------------------------------------------------------------- */
1864
1865  function readDifficulty() {
1866    var r = els.root && els.root.querySelector('input[name="kbDifficulty"]:checked');
1867    var v = r && r.value ? r.value : "easy";
1868    state.bpm = BPM_BY_DIFF[v] || 90;
1869  }
1870
1871  function setKbText(id, value) {
1872    setTextById(id, value);
1873  }
1874
1875  function updateCampaignStartBrief() {
1876    var gp = getBeatGameplayConfig(1);
1877    setKbText("kbCampaignLevel", "LEVEL 1");
1878    setKbText("kbCampaignGoal", "목표 정확도: " + gp.requiredAccuracy + "%");
1879    setKbText(
1880      "kbCampaignBrief",
1881      "떨어지는 키를 정확히 입력하세요 · 레벨당 " + LEVEL_DURATION + "초"
1882    );
1883  }
1884
1885  function hasMinimalPlayfieldDom() {
1886    return !!(els.game && els.lane && els.lanesBg && els.notesLayer && els.laneLabels);
1887  }
1888
1889  function warnMissingPlayfield(context) {
1890    if (hasMinimalPlayfieldDom()) return true;
1891    console.warn(
1892      "[Keyboard Beat] Playfield DOM incomplete (" + (context || "") + "). Keeping start screen."
1893    );
1894    return false;
1895  }
1896
1897  function readDifficultySlug() {
1898    var r = els.root && els.root.querySelector('input[name="kbDifficulty"]:checked');
1899    return r && r.value ? r.value : "easy";
1900  }
1901
1902  function keyModeLabel(mode) {
1903    var m = {
1904      home: "홈",
1905      left: "왼손",
1906      right: "오른손",
1907      numbers: "숫자",
1908      symbols: "기호",
1909    };
1910    return m[mode] || mode;
1911  }
1912
1913  function keyModeSummaryLabel(mode) {
1914    var m = {
1915      home: "홈 포지션",
1916      left: "왼손",
1917      right: "오른손",
1918      numbers: "숫자",
1919      symbols: "코딩 기호",
1920    };
1921    return m[mode] || mode;
1922  }
1923
1924  function difficultySummaryName(slug) {
1925    if (slug === "normal") return "Normal";
1926    if (slug === "hard") return "Hard";
1927    return "Easy";
1928  }
1929
1930  function formatKeysPreview(keys) {
1931    if (!keys || !keys.length) return "";
1932    if (keys.length > 20) return "A ~ Z";
1933    return keys.join(" ");
1934  }
1935
1936  /** 한 줄: Lv · 레벨 제목 · 키 나열 (설정·시작 카드용) */
1937  function formatLevelKeysOneLine(levelNum, cfg) {
1938    var c = cfg || getLevelConfig(levelNum);
1939    return "Lv." + levelNum + " · " + c.title + " · " + formatKeysPreview(c.keys);
1940  }
1941
1942  /** 짧게: Lv · 키 나열 (플레이 HUD용) */
1943  function formatLevelKeysPlayHud(levelNum, cfg) {
1944    var c = cfg || getLevelConfig(levelNum);
1945    return "Lv." + levelNum + " · " + formatKeysPreview(c.keys);
1946  }
1947
1948  function updateStartLevelCard() {
1949    var cfg = getLevelConfig(state.selectedLevel);
1950    if (els.kbStartLevelOneLine) {
1951      els.kbStartLevelOneLine.textContent = cfg.title + " · " + formatKeysPreview(cfg.keys);
1952    }
1953    if (els.kbStartLevelKeys) {
1954      els.kbStartLevelKeys.textContent = formatKeysPreview(cfg.keys);
1955    }
1956    if (els.kbStartLevelClear) {
1957      var gp1 = getBeatGameplayConfig(1);
1958      els.kbStartLevelClear.textContent =
1959        "레벨당 " +
1960        LEVEL_DURATION +
1961        "초 · 정확도 " +
1962        gp1.requiredAccuracy +
1963        "% 이상 클리어 · 점수 누적";
1964    }
1965    updateCampaignStartBrief();
1966    if (els.kbSetupSummaryKeys) {
1967      els.kbSetupSummaryKeys.textContent = "키: " + formatKeysPreview(cfg.keys);
1968    }
1969    syncKbSettingsKeysBrief();
1970  }
1971
1972  function syncKbSettingsKeysBrief() {
1973    if (!els.kbSettingsCurrentKeysBrief) return;
1974    var cfg = getLevelConfig(state.selectedLevel);
1975    els.kbSettingsCurrentKeysBrief.textContent = formatLevelKeysOneLine(state.selectedLevel, cfg);
1976  }
1977
1978  function updatePlayHudLevelBrief() {
1979    if (!els.kbPlayLevelBrief) return;
1980    els.kbPlayLevelBrief.textContent = "LEVEL " + state.currentLevel;
1981  }
1982
1983  function syncBeatProgressFromStore() {
1984    state.unlockedLevel = BeatProgressStore.getUnlockedLevel();
1985    var sel = BeatProgressStore.getSelectedLevel();
1986    sel = BeatProgressStore._clampLevel(sel);
1987    if (sel > state.unlockedLevel) sel = state.unlockedLevel;
1988    state.selectedLevel = sel;
1989    BeatProgressStore.setSelectedLevel(state.selectedLevel);
1990    resolveBeatKeys();
1991  }
1992
1993  function syncLevelPicker() {
1994    if (!els.kbLevelPicker) return;
1995    els.kbLevelPicker.innerHTML = "";
1996    for (var i = 1; i <= 10; i++) {
1997      var cfg = getLevelConfig(i);
1998      var btn = document.createElement("button");
1999      btn.type = "button";
2000      btn.className = "kb-level-pick";
2001      btn.setAttribute("data-beat-level", String(i));
2002      btn.setAttribute("role", "option");
2003      btn.setAttribute("aria-selected", i === state.selectedLevel ? "true" : "false");
2004      btn.innerHTML =
2005        '<span class="kb-level-pick__num">Lv.' +
2006        i +
2007        '</span><span class="kb-level-pick__meta">' +
2008        cfg.title +
2009        "</span>";
2010      if (i > state.unlockedLevel) {
2011        btn.disabled = true;
2012        btn.classList.add("kb-level-pick--locked");
2013        btn.setAttribute("aria-disabled", "true");
2014      }
2015      if (i === state.selectedLevel) btn.classList.add("kb-level-pick--current");
2016      els.kbLevelPicker.appendChild(btn);
2017    }
2018  }
2019
2020  function applyBeatLevelResultHeadline(cleared) {
2021    var meta = state.endGameMeta;
2022    if (els.kbResultLevelBlock) els.kbResultLevelBlock.classList.remove("is-hidden");
2023    if (meta && meta.reason === "accuracy_failed") {
2024      if (els.resultOverlay) els.resultOverlay.classList.remove("kb-result--beat-level-clear");
2025      if (els.resultTitle) els.resultTitle.textContent = "GAME OVER";
2026      if (els.resultEndReason) {
2027        els.resultEndReason.textContent =
2028          "LEVEL " +
2029          meta.level +
2030          " 실패 · 정확도 " +
2031          meta.accuracy +
2032          "% / 목표 " +
2033          meta.requiredAccuracy +
2034          "%";
2035      }
2036      if (els.kbResultLevelMsg) {
2037        els.kbResultLevelMsg.textContent =
2038          "LEVEL " + meta.level + " · 정확도 " + meta.accuracy + "% / 목표 " + meta.requiredAccuracy + "%";
2039        els.kbResultLevelMsg.classList.remove("kb-result-level-msg--success");
2040        els.kbResultLevelMsg.classList.add("kb-result-level-msg--fail");
2041      }
2042      return;
2043    }
2044    if (cleared) {
2045      if (els.resultOverlay) els.resultOverlay.classList.add("kb-result--beat-level-clear");
2046      if (els.resultTitle) els.resultTitle.textContent = "플레이 완료";
2047      if (els.resultEndReason) {
2048        els.resultEndReason.textContent =
2049          "최고 LEVEL " + state.maxLevelReached + " · 클리어 " + state.sessionLevelsCleared + "회";
2050      }
2051      if (els.kbResultLevelMsg) {
2052        els.kbResultLevelMsg.textContent = "LEVEL " + state.maxLevelReached + "까지 도달";
2053        els.kbResultLevelMsg.classList.remove("kb-result-level-msg--fail");
2054        els.kbResultLevelMsg.classList.add("kb-result-level-msg--success");
2055      }
2056    } else {
2057      if (els.resultOverlay) els.resultOverlay.classList.remove("kb-result--beat-level-clear");
2058      if (els.resultTitle) els.resultTitle.textContent = "GAME OVER";
2059      if (els.resultEndReason) {
2060        els.resultEndReason.textContent =
2061          "LEVEL " + state.maxLevelReached + " · 최종 점수 " + state.score;
2062      }
2063      if (els.kbResultLevelMsg) {
2064        els.kbResultLevelMsg.textContent = "다시 도전해보세요.";
2065        els.kbResultLevelMsg.classList.remove("kb-result-level-msg--success");
2066        els.kbResultLevelMsg.classList.add("kb-result-level-msg--fail");
2067      }
2068    }
2069  }
2070
2071  function syncBeatResultActionEmphasis(cleared) {
2072    var mains = els.root ? els.root.querySelectorAll(".kb-btn--result-main") : [];
2073    for (var mi = 0; mi < mains.length; mi++) {
2074      mains[mi].classList.remove("kb-btn--result-hero");
2075    }
2076    if (els.againBtn) els.againBtn.classList.add("kb-btn--result-hero");
2077    if (els.kbGuestAgainBtn) els.kbGuestAgainBtn.classList.add("kb-btn--result-hero");
2078  }
2079
2080  function updateResultLevelUi(cleared) {
2081    applyBeatLevelResultHeadline(cleared);
2082    if (els.kbNextLevelBtns && els.kbNextLevelBtns.length) {
2083      for (var bi = 0; bi < els.kbNextLevelBtns.length; bi++) {
2084        els.kbNextLevelBtns[bi].classList.add("is-hidden");
2085      }
2086    }
2087    if (els.kbGuestAgainBtn) {
2088      els.kbGuestAgainBtn.textContent = "다시 도전";
2089      els.kbGuestAgainBtn.classList.toggle("kb-btn--secondary", !!cleared);
2090      els.kbGuestAgainBtn.classList.toggle("kb-btn--primary", !cleared);
2091    }
2092    if (els.againBtn) {
2093      els.againBtn.textContent = "다시 도전";
2094      els.againBtn.classList.toggle("kb-btn--secondary", !!cleared);
2095      els.againBtn.classList.toggle("kb-btn--primary", !cleared);
2096    }
2097    syncBeatResultActionEmphasis(cleared);
2098    syncKbResultPrimaryActionsLayout();
2099  }
2100
2101  /** 레벨 클리어: 큰 [랭킹 보기] 숨기고 보조 텍스트 링크만. 실패/비클리어: 그 반대. */
2102  function syncKbBeatResultFooterLayout(levelClearMode) {
2103    if (els.kbGuestResultRankingBtn) els.kbGuestResultRankingBtn.classList.toggle("is-hidden", !!levelClearMode);
2104    if (els.kbGuestResultRankingLink) els.kbGuestResultRankingLink.classList.toggle("is-hidden", !levelClearMode);
2105    if (els.kbLoggedResultRankingBtn) els.kbLoggedResultRankingBtn.classList.toggle("is-hidden", !!levelClearMode);
2106    if (els.kbLoggedResultRankingLink) els.kbLoggedResultRankingLink.classList.toggle("is-hidden", !levelClearMode);
2107    if (levelClearMode) {
2108      setBeatGuestHomeLinkHidden(true);
2109    } else {
2110      setBeatGuestHomeLinkHidden(false);
2111    }
2112    syncKbResultPrimaryActionsLayout();
2113  }
2114
2115  function hideResultLevelExtras() {
2116    if (els.kbResultLevelBlock) els.kbResultLevelBlock.classList.add("is-hidden");
2117    if (els.kbNextLevelBtns && els.kbNextLevelBtns.length) {
2118      for (var bi = 0; bi < els.kbNextLevelBtns.length; bi++) {
2119        els.kbNextLevelBtns[bi].classList.add("is-hidden");
2120      }
2121    }
2122  }
2123
2124  function goToNextLevelFromResult() {
2125    if (state.selectedLevel >= 10) return;
2126    if (!isKbLoggedIn() && typeof PlayLabGuest !== "undefined" && !PlayLabGuest.canGuestStartAnother("keyboard_beat")) {
2127      PlayLabGuest.showGuestLoginModal({ variant: "daily_limit", game: "keyboard_beat" });
2128      return;
2129    }
2130    state.selectedLevel = BeatProgressStore._clampLevel(state.selectedLevel + 1);
2131    BeatProgressStore.setSelectedLevel(state.selectedLevel);
2132    resolveBeatKeys();
2133    if (els.resultOverlay) els.resultOverlay.classList.add("is-hidden");
2134    hideBeatRankingSectionFull();
2135    kickoffKeyboardBeatRound();
2136  }
2137
2138  function updateSetupSummary() {
2139    if (!els.kbSetupSummary) return;
2140    var slug = readDifficultySlug();
2141    var bpm = BPM_BY_DIFF[slug] != null ? BPM_BY_DIFF[slug] : BPM_BY_DIFF.easy;
2142    var cfg = getLevelConfig(state.selectedLevel);
2143    els.kbSetupSummary.textContent =
2144      "Level " +
2145      state.selectedLevel +
2146      " · " +
2147      difficultySummaryName(slug) +
2148      " · " +
2149      bpm +
2150      " BPM";
2151    if (els.kbSetupSummaryKeys) {
2152      els.kbSetupSummaryKeys.textContent = "키: " + formatKeysPreview(cfg.keys);
2153    }
2154    syncKbSettingsKeysBrief();
2155  }
2156
2157  function closeKbSettingsLayer() {
2158    if (!els.kbSettingsLayer) return;
2159    els.kbSettingsLayer.classList.add("is-hidden");
2160    els.kbSettingsLayer.setAttribute("aria-hidden", "true");
2161    document.body.classList.remove("kb-beat-settings-open");
2162  }
2163
2164  function closeKbBeatPreviewModal() {
2165    if (!els.kbBeatPreviewLayer) return;
2166    els.kbBeatPreviewLayer.classList.add("is-hidden");
2167    els.kbBeatPreviewLayer.setAttribute("aria-hidden", "true");
2168  }
2169
2170  function openKbSettingsLayer() {
2171    if (!els.kbSettingsLayer) return;
2172    closeKbRankingLayer();
2173    closeKbBeatPreviewModal();
2174    els.kbSettingsLayer.classList.remove("is-hidden");
2175    els.kbSettingsLayer.setAttribute("aria-hidden", "false");
2176    document.body.classList.add("kb-beat-settings-open");
2177    syncLevelPicker();
2178    updateSetupSummary();
2179  }
2180
2181  /**
2182   * 랭킹 key_mode 슬러그. #kbKeySet UI 제거 후에도 레벨 키 배열(KEYBOARD_BEAT_LEVELS)과 헷갈리지 않게
2183   * 이 값만으로 플레이 키를 추론하지 말 것. 레벨/키 매핑을 기록하고 싶다면 API 확장 검토.
2184   */
2185  function readKeySet() {
2186    var v = els.keySet && els.keySet.value ? String(els.keySet.value).trim() : "";
2187    return KEY_SETS[v] ? v : "home";
2188  }
2189
2190  /** Keyboard Race 와 동일한 오락실 닉네임 규칙 (클라이언트) */
2191  function validateNicknameClient(name) {
2192    var normalized = normalizeArcadeName(name);
2193    return normalized.length >= 2 && normalized.length <= 10;
2194  }
2195
2196  function normalizeArcadeName(raw) {
2197    return String(raw || "")
2198      .trim()
2199      .replace(/[^A-Za-z0-9가-힣]/g, "")
2200      .slice(0, 10);
2201  }
2202
2203  function beatRankingDurationMs() {
2204    if (state.lastDurationMs) return state.lastDurationMs;
2205    if (state.gameStartPerf > 0) {
2206      return Math.max(0, Math.round(performance.now() - state.gameStartPerf));
2207    }
2208    return 0;
2209  }
2210
2211  function buildBeatRankingPayload(accNum, guestPlayerName) {
2212    var diff = readDifficultySlug();
2213    var keyMode = readKeySet();
2214    var lc = getLevelConfig(state.maxLevelReached);
2215    var body = {
2216      score: state.score,
2217      max_combo: state.maxCombo,
2218      accuracy: accNum,
2219      perfect_count: state.perfect,
2220      good_count: state.good,
2221      miss_count: state.miss,
2222      difficulty: diff,
2223      bpm: state.bpm,
2224      key_mode: keyMode,
2225      duration_ms: beatRankingDurationMs(),
2226      input_mode: state.inputMode === "touch" ? "touch" : "keyboard",
2227      level: state.maxLevelReached,
2228      level_label: "LEVEL " + state.maxLevelReached + " · " + lc.title,
2229      level_score_multiplier: lc.scoreMultiplier != null ? lc.scoreMultiplier : 1,
2230    };
2231    if (guestPlayerName != null && guestPlayerName !== "") {
2232      body.player_name = guestPlayerName;
2233    }
2234    return body;
2235  }
2236
2237  function parseRankingFetchResponse(res) {
2238    return res.text().then(function (txt) {
2239      var data = {};
2240      try {
2241        data = txt ? JSON.parse(txt) : {};
2242      } catch (eParse) {
2243        data = { message: txt ? String(txt).slice(0, 200) : "Invalid JSON response" };
2244      }
2245      return { ok: res.ok, status: res.status, data: data };
2246    });
2247  }
2248
2249  function readStoredBeatRankingDisplayName() {
2250    try {
2251      return String(localStorage.getItem(KB_RANKING_DISPLAY_NAME_KEY) || "").trim();
2252    } catch (eRead) {
2253      return "";
2254    }
2255  }
2256
2257  function writeStoredBeatRankingDisplayName(name) {
2258    try {
2259      localStorage.setItem(KB_RANKING_DISPLAY_NAME_KEY, String(name || "").trim());
2260    } catch (eWrite) {
2261      /* ignore */
2262    }
2263  }
2264
2265  function getActiveBeatResultActionsRow() {
2266    var host = isKbLoggedIn() ? els.kbLoggedResultActions : els.kbGuestResultActions;
2267    return host ? host.querySelector(".kb-result__actions-row--primary") : null;
2268  }
2269
2270  function syncKbResultPrimaryActionsLayout() {
2271    var row = getActiveBeatResultActionsRow();
2272    if (!row) return;
2273    var visible = row.querySelectorAll(".kb-btn--result-main:not(.is-hidden
2273)");
2274    row.classList.remove("kb-result__actions-row--count-1", "kb-result__actions-row--count-2");
2275    if (visible.length === 1) row.classList.add("kb-result__actions-row--count-1");
2276    else if (visible.length >= 2) row.classList.add("kb-result__actions-row--count-2");
2277  }
2278
2279  function setBeatResultFooterVisible(visible) {
2280    var actions = isKbLoggedIn() ? els.kbLoggedResultActions : els.kbGuestResultActions;
2281    if (actions) actions.classList.toggle("is-hidden", !visible);
2282    syncKbResultPrimaryActionsLayout();
2283  }
2284
2285  function setBeatGuestHomeLinkHidden(hidden) {
2286    if (!els.kbGuestToSetupBtn) return;
2287    els.kbGuestToSetupBtn.classList.toggle("is-hidden", !!hidden);
2288  }
2289
2290  function resetBeatRankingSaveForm() {
2291    state.beatRankingSaveState = "idle";
2292    if (els.kbResultNameInput) {
2293      els.kbResultNameInput.value = "";
2294      els.kbResultNameInput.disabled = false;
2295    }
2296    if (els.kbResultSaveRankBtn) {
2297      els.kbResultSaveRankBtn.disabled = false;
2298      els.kbResultSaveRankBtn.classList.remove("is-hidden");
2299    }
2300    if (els.kbResultNameError) {
2301      els.kbResultNameError.textContent = "";
2302      els.kbResultNameError.classList.add("is-hidden");
2303    }
2304    if (els.kbResultRankingSavedStatus) els.kbResultRankingSavedStatus.classList.add("is-hidden");
2305    if (els.kbResultRankingRetryBtn) els.kbResultRankingRetryBtn.classList.add("is-hidden");
2306  }
2307
2308  function loadBeatResultRankingTop10() {
2309    if (!els.kbResultRankingTableBody || !els.kbResultRankingTableWrap) return;
2310    els.kbResultRankingTableWrap.classList.remove("is-hidden");
2311    els.kbResultRankingTableBody.innerHTML = '<tr><td colspan="3">불러오는 중…</td></tr>';
2312    fetch(KB_API_LEADERBOARD + "?limit=10", { credentials: "same-origin", headers: { Accept: "application/json" } })
2313      .then(function (res) {
2314        return res.json();
2315      })
2316      .then(function (data) {
2317        if (!els.kbResultRankingTableBody) return;
2318        var rows = data && data.success && data.rankings ? data.rankings.slice(0, 10) : [];
2319        if (!rows.length) {
2320          els.kbResultRankingTableBody.innerHTML = '<tr><td colspan="3">아직 기록이 없어요.</td></tr>';
2321          return;
2322        }
2323        els.kbResultRankingTableBody.innerHTML = rows
2324          .map(function (row) {
2325            var rankNum = Number(row.rank || 0);
2326            var rankText = rankNum === 1 ? "🥇 1" : rankNum === 2 ? "🥈 2" : rankNum === 3 ? "🥉 3" : String(rankNum);
2327            var name = row.display_name || row.player_name || "—";
2328            return (
2329              "<tr><td>" +
2330              rankText +
2331              "</td><td>" +
2332              String(name) +
2333              "</td><td>" +
2334              Number(row.score || 0) +
2335              "</td></tr>"
2336            );
2337          })
2338          .join("");
2339      })
2340      .catch(function () {
2341        if (els.kbResultRankingTableBody) {
2342          els.kbResultRankingTableBody.innerHTML = '<tr><td colspan="3">랭킹을 불러오지 못했습니다.</td></tr>';
2343        }
2344      });
2345  }
2346
2347  function goToBeatStartAfterSave() {
2348    if (els.resultOverlay) {
2349      els.resultOverlay.classList.add("is-hidden");
2350      els.resultOverlay.classList.remove("kb-result--score-save");
2351    }
2352    document.body.classList.remove("kb-beat-ranking-open");
2353    hideBeatRankingSectionFull();
2354    resetBeatResultOverlayChrome();
2355    showSetup();
2356  }
2357
2358  function hideBeatRankingSectionFull() {
2359    if (els.resultOverlay) els.resultOverlay.classList.remove("kb-result--score-save");
2360    if (!els.kbResultRankingSection) return;
2361    els.kbResultRankingSection.classList.add("is-hidden");
2362    els.kbResultRankingSection.classList.remove(
2363      "kb-result-ranking--entry",
2364      "kb-result-ranking--saved",
2365      "kb-result-ranking--secondary"
2366    );
2367    if (els.kbResultRankingSaveForm) els.kbResultRankingSaveForm.classList.remove("is-hidden");
2368    if (els.kbResultRankNotice) {
2369      els.kbResultRankNotice.textContent = "";
2370      els.kbResultRankNotice.classList.add("is-hidden");
2371    }
2372    if (els.resultRankHint) {
2373      els.resultRankHint.textContent = "";
2374      els.resultRankHint.classList.add("is-hidden");
2375    }
2376    resetBeatRankingSaveForm();
2377  }
2378
2379  function fetchBeatRankingPositionAfterResult(accNum, cleared) {
2380    var qs = new URLSearchParams({
2381      score: String(state.score),
2382      accuracy: String(accNum),
2383      miss_count: String(state.miss),
2384      difficulty: readDifficultySlug(),
2385      level: String(state.maxLevelReached),
2386      input_mode: state.inputMode === "touch" ? "touch" : "keyboard",
2387    });
2388    return fetch(KB_API_POSITION + "?" + qs.toString(), {
2389      credentials: "same-origin",
2390      headers: { Accept: "application/json" },
2391    })
2392      .then(function (res) {
2393        return res.json().catch(function () {
2394          return {};
2395        });
2396      })
2397      .then(function (data) {
2398        if (state.phase !== "ended") return;
2399        if (!els.resultOverlay || els.resultOverlay.classList.contains("is-hidden")) return;
2400        var isTop10 = !!(data && data.success && data.is_top10);
2401        var rankNum = data && data.rank != null ? Number(data.rank) : 0;
2402        if (isTop10) {
2403          showBeatRankingEntryUi(rankNum);
2404        } else {
2405          hideBeatRankingSectionFull();
2406          if (!cleared && els.resultRankHint) {
2407            els.resultRankHint.textContent = "아쉽지만 랭킹권에는 들지 못했어요.";
2408            els.resultRankHint.classList.remove("is-hidden");
2409          }
2410        }
2411        syncKbResultPrimaryActionsLayout();
2412      })
2413      .catch(function () {
2414        if (state.phase !== "ended") return;
2415        if (!els.resultOverlay || els.resultOverlay.classList.contains("is-hidden")) return;
2416        hideBeatRankingSectionFull();
2417        if (!cleared && els.resultRankHint) {
2418          els.resultRankHint.textContent = "순위를 확인하지 못했어요.";
2419          els.resultRankHint.classList.remove("is-hidden");
2420        }
2421      });
2422  }
2423
2424  function resetBeatResultOverlayChrome() {
2425    if (els.resultOverlay) {
2426      els.resultOverlay.classList.remove("kb-result--beat-level-clear");
2427    }
2428    syncKbBeatResultFooterLayout(false);
2429    hideBeatRankingSectionFull();
2430    setBeatResultFooterVisible(true);
2431  }
2432
2433  function showBeatRankingEntryUi(rankNum) {
2434    if (!els.kbResultRankingSection) return;
2435    if (els.resultOverlay) els.resultOverlay.classList.add("kb-result--score-save");
2436    resetBeatRankingSaveForm();
2437    if (els.kbResultSaveRankBtn) els.kbResultSaveRankBtn.textContent = "점수 저장";
2438    loadBeatResultRankingTop10();
2439    els.kbResultRankingSection.classList.remove("is-hidden", "kb-result-ranking--saved");
2440    els.kbResultRankingSection.classList.add("kb-result-ranking--entry", "kb-result-ranking--secondary");
2441    if (els.kbResultRankingHeading) els.kbResultRankingHeading.textContent = "랭킹 진입";
2442    if (els.kbResultRankingPrompt) {
2443      els.kbResultRankingPrompt.textContent =
2444        rankNum && rankNum <= 10
2445          ? "TOP " + rankNum + "! 이름을 입력하고 기록을 저장해 주세요."
2446          : "이름을 입력하고 기록을 저장해 주세요.";
2447    }
2448    if (els.kbResultRankNotice) {
2449      if (rankNum) {
2450        els.kbResultRankNotice.textContent = "예상 순위: " + rankNum + "위";
2451        els.kbResultRankNotice.classList.remove("is-hidden");
2452      } else {
2453        els.kbResultRankNotice.textContent = "";
2454        els.kbResultRankNotice.classList.add("is-hidden");
2455      }
2456    }
2457    if (els.kbResultRankingSaveForm) els.kbResultRankingSaveForm.classList.remove("is-hidden");
2458    if (els.kbResultNameInput) {
2459      els.kbResultNameInput.value = readStoredBeatRankingDisplayName();
2460      els.kbResultNameInput.disabled = false;
2461    }
2462    if (els.resultGuestNudge) {
2463      els.resultGuestNudge.classList.toggle("is-hidden", isKbLoggedIn());
2464    }
2465    syncKbBeatResultFooterLayout(!!state.lastRoundCleared);
2466    setBeatResultFooterVisible(true);
2467    syncBeatResultActionEmphasis(!!state.lastRoundCleared);
2468  }
2469
2470  function showBeatRankingSavedUi(isPb) {
2471    if (!els.kbResultRankingSection) return;
2472    els.kbResultRankingSection.classList.remove("is-hidden", "kb-result-ranking--entry");
2473    els.kbResultRankingSection.classList.add("kb-result-ranking--saved");
2474    if (els.kbResultRankingSaveForm) els.kbResultRankingSaveForm.classList.add("is-hidden");
2475    if (els.kbResultRankingSavedStatus) {
2476      els.kbResultRankingSavedStatus.textContent = "기록이 저장되었습니다";
2477      els.kbResultRankingSavedStatus.classList.remove("is-hidden");
2478    }
2479    if (els.kbResultRankingRetryBtn) els.kbResultRankingRetryBtn.classList.add("is-hidden");
2480    if (els.resultRecordNote) {
2481      if (isPb) {
2482        els.resultRecordNote.textContent = "신기록!";
2483        els.resultRecordNote.classList.remove("is-hidden");
2484      } else {
2485        els.resultRecordNote.textContent = "";
2486        els.resultRecordNote.classList.add("is-hidden");
2487      }
2488    }
2489    if (els.resultGuestNudge) els.resultGuestNudge.classList.add("is-hidden");
2490    if (els.resultRankHint) {
2491      els.resultRankHint.textContent = "기록이 저장되었습니다.";
2492      els.resultRankHint.classList.remove("is-hidden");
2493    }
2494    syncKbBeatResultFooterLayout(!!state.lastRoundCleared);
2495    setBeatResultFooterVisible(true);
2496    syncBeatResultActionEmphasis(!!state.lastRoundCleared);
2497  }
2498
2499  function submitBeatRanking(accNum) {
2500    if (state.beatRankingSaveState === "success" || state.beatRankingSaveState === "pending") return;
2501
2502    var raw = els.kbResultNameInput ? els.kbResultNameInput.value : "";
2503    if (!String(raw || "").trim()) {
2504      if (els.kbResultNameError) {
2505        els.kbResultNameError.textContent = "이름을 입력해주세요.";
2506        els.kbResultNameError.classList.remove("is-hidden");
2507      }
2508      return;
2509    }
2510    var name = normalizeArcadeName(raw);
2511    if (!validateNicknameClient(name)) {
2512      if (els.kbResultNameError) {
2513        els.kbResultNameError.textContent = "2~10자(한글/영문/숫자)로 입력해주세요.";
2514        els.kbResultNameError.classList.remove("is-hidden");
2515      }
2516      return;
2517    }
2518    if (els.kbResultNameError) els.kbResultNameError.classList.add("is-hidden");
2519
2520    state.beatRankingSaveState = "pending";
2521    if (els.kbResultSaveRankBtn) {
2522      els.kbResultSaveRankBtn.disabled = true;
2523      els.kbResultSaveRankBtn.textContent = "저장 중...";
2524    }
2525    if (els.kbResultRankingRetryBtn) els.kbResultRankingRetryBtn.classList.add("is-hidden");
2526
2527    var body = buildBeatRankingPayload(accNum, name);
2528    fetch(KB_API_RANKING, {
2529      method: "POST",
2530      headers: { "Content-Type": "application/json", Accept: "application/json" },
2531      credentials: "same-origin",
2532      body: JSON.stringify(body),
2533    })
2534      .then(parseRankingFetchResponse)
2535      .then(function (out) {
2536        if (out.ok && out.data && out.data.success) {
2537          state.beatRankingSaveState = "success";
2538          writeStoredBeatRankingDisplayName(name);
2539          if (typeof PlayLabGuest !== "undefined" && PlayLabGuest.clearPendingResult) {
2540            PlayLabGuest.clearPendingResult();
2541          }
2542          goToBeatStartAfterSave();
2543        } else {
2544          state.beatRankingSaveState = "failed";
2545          if (els.kbResultSaveRankBtn) {
2546            els.kbResultSaveRankBtn.disabled = false;
2547            els.kbResultSaveRankBtn.textContent = "점수 저장";
2548          }
2549          if (els.kbResultNameError) {
2550            els.kbResultNameError.textContent =
2551              (out.data && out.data.message) || "랭킹 등록에 실패했습니다. 다시 시도해주세요.";
2552            els.kbResultNameError.classList.remove("is-hidden");
2553          }
2554          if (els.kbResultRankingRetryBtn) els.kbResultRankingRetryBtn.classList.remove("is-hidden");
2555          console.warn("[keyboard-beat] ranking POST rejected", out.status, out.data);
2556        }
2557      })
2558      .catch(function (err) {
2559        state.beatRankingSaveState = "failed";
2560        if (els.kbResultSaveRankBtn) {
2561          els.kbResultSaveRankBtn.disabled = false;
2562          els.kbResultSaveRankBtn.textContent = "점수 저장";
2563        }
2564        if (els.kbResultNameError) {
2565          els.kbResultNameError.textContent = "랭킹 등록에 실패했습니다. 다시 시도해주세요.";
2566          els.kbResultNameError.classList.remove("is-hidden");
2567        }
2568        if (els.kbResultRankingRetryBtn) els.kbResultRankingRetryBtn.classList.remove("is-hidden");
2569        console.error("[keyboard-beat] ranking POST network error", err);
2570      });
2571  }
2572
2573  function isBeatGameVisible() {
2574    return !!(els.game && !els.game.classList.contains("is-hidden"));
2575  }
2576
2577  function setBeatPlayingLayout(active) {
2578    var on = !!active && isBeatGameVisible();
2579    document.body.classList.toggle("kb-beat-playing", on);
2580    if (els.root) els.root.classList.toggle("kb-beat-root--playing", on);
2581    var panel = document.getElementById("playLabPanelKeyboardBeat");
2582    if (panel) panel.classList.toggle("kb-beat-panel--playing", on);
2583    if (on) {
2584      updateAppHeight();
2585      requestAnimationFrame(function () {
2586        updateAppHeight();
2587        relayoutLaneGrid();
2588      });
2589    }
2590  }
2591
2592  function showSetup() {
2593    try {
2594      setBeatPlayingLayout(false);
2595      beatChrome.teardown();
2596      closeKbSettingsLayer();
2597      closeKbBeatPreviewModal();
2598      resetState();
2599      syncBeatProgressFromStore();
2600      resolveBeatKeys();
2601      updatePlayHud();
2602      setTimeLeft(LEVEL_DURATION);
2603      hideResultLevelExtras();
2604      resetBeatResultOverlayChrome();
2605      if (els.resultOverlay) els.resultOverlay.classList.add("is-hidden");
2606      if (els.resultRankHint) {
2607        els.resultRankHint.textContent = "";
2608        els.resultRankHint.classList.add("is-hidden");
2609      }
2610      if (els.resultGuestNudge) els.resultGuestNudge.classList.add("is-hidden");
2611      if (els.game) els.game.classList.add("is-hidden");
2612      if (els.countdown) els.countdown.classList.add("is-hidden");
2613      if (els.setup) {
2614        els.setup.classList.remove("is-hidden");
2615        els.setup.removeAttribute("hidden");
2616        els.setup.removeAttribute("aria-hidden");
2617        els.setup.style.removeProperty("display");
2618        els.setup.style.removeProperty("visibility");
2619        els.setup.style.removeProperty("opacity");
2620        els.setup.style.removeProperty("height");
2621        els.setup.style.removeProperty("max-height");
2622      }
2623      if (els.kbHeader) els.kbHeader.classList.remove("is-hidden");
2624      if (els.kbLayout) {
2625        els.kbLayout.classList.remove("is-hidden");
2626        els.kbLayout.style.removeProperty("display");
2627        els.kbLayout.style.removeProperty("height");
2628      }
2629      if (els.root) {
2630        els.root.style.removeProperty("height");
2631        els.root.style.removeProperty("min-height");
2632      }
2633      updateStartLevelCard();
2634      updateSetupSummary();
2635      updateCampaignStartBrief();
2636    } catch (errSetup) {
2637      console.error("[Keyboard Beat] showSetup failed", errSetup);
2638    }
2639  }
2640
2641  function showGameUi() {
2642    if (els.resultOverlay) els.resultOverlay.classList.add("is-hidden");
2643    if (els.setup) {
2644      els.setup.classList.add("is-hidden");
2645      els.setup.setAttribute("aria-hidden", "true");
2646    }
2647    if (els.game) els.game.classList.remove("is-hidden");
2648    setBeatPlayingLayout(true);
2649  }
2650
2651  function isKbLoggedIn() {
2652    return !!(els.root && els.root.getAttribute("data-kb-logged-in") === "1");
2653  }
2654
2655  function runCountdown(done) {
2656    if (!els.countdown) return done();
2657    els.countdown.classList.remove("is-hidden");
2658    var n = COUNTDOWN_SEC;
2659    function step() {
2660      if (n <= 0) {
2661        els.countdown.textContent = "";
2662        els.countdown.classList.add("is-hidden");
2663        done();
2664        return;
2665      }
2666      els.countdown.textContent = String(n);
2667      n -= 1;
2668      setTimeout(step, 1000);
2669    }
2670    step();
2671  }
2672
2673  function startBeatMetronome() {
2674    clearInterval(state.beatTimerId);
2675    var interval = 60000 / state.bpm;
2676    state.beatTimerId = setInterval(function () {
2677      if (state.phase === "playing") Sound.beatTick();
2678    }, interval);
2679  }
2680
2681  /** 프리뷰 키보드의 data-kb-preview-key 속성값과 매칭 (문자 레이블 표준화) */
2682  function normalizeKbPreviewKeyAttr(raw) {
2683    var s = String(raw != null ? raw : "");
2684    if (s.length === 1 && s >= "a" && s <= "z") return s.toUpperCase();
2685    return s;
2686  }
2687
2688  /** 이번 판 노트에 쓰이는 KEYBOARD_BEAT_LEVELS[].keys 를 그대로 나열해 표시 */
2689  function formatKeysListForBeatPreview(keys) {
2690    if (!keys || !keys.length) return "—";
2691    return keys.join(" ");
2692  }
2693
2694  /** 프리뷰 안내용: 레벨 키에 라틴 알파벳이 있으면 대소문자 무관 안내 노출 */
2695  function beatPreviewShowsCaseRelaxNote(keys) {
2696    if (!keys || !keys.length) return false;
2697    var i;
2698    for (i = 0; i < keys.length; i++) {
2699      if (/[a-zA-Z]/.test(String(keys[i] || ""))) return true;
2700    }
2701    return false;
2702  }
2703
2704  function renderKbBeatPreviewHighlights(normKeys) {
2705    if (!els.kbBeatPreviewKeyboard) return;
2706    var set = {};
2707    for (var i = 0; i < normKeys.length; i++) set[normKeys[i]] = true;
2708    var nodes = els.kbBeatPreviewKeyboard.querySelectorAll(".kr-guide-key[data-kb-preview-key]");
2709    nodes.forEach(function (el) {
2710      var a = el.getAttribute("data-kb-preview-key");
2711      el.classList.toggle("is-active", !!set[a]);
2712    });
2713  }
2714
2715  function populateKbBeatPreviewModal() {
2716    var cfg = getLevelConfig(state.selectedLevel);
2717    var keys = cfg.keys || [];
2718    var norm = [];
2719    for (var i = 0; i < keys.length; i++) norm.push(normalizeKbPreviewKeyAttr(keys[i]));
2720    if (els.kbBeatPreviewLevelLine) {
2721      els.kbBeatPreviewLevelLine.textContent =
2722        "LEVEL 1부터 시작 · 레벨당 " + LEVEL_DURATION + "초 · " + cfg.title;
2723    }
2724    if (els.kbBeatPreviewKeysLine) els.kbBeatPreviewKeysLine.textContent = formatKeysListForBeatPreview(keys);
2725    if (els.kbBeatPreviewCaseHint) {
2726      if (beatPreviewShowsCaseRelaxNote(keys)) {
2727        els.kbBeatPreviewCaseHint.textContent =
2728          "대문자로 보여도 같은 알파벳 키를 누르면 정답입니다. Shift나 Caps Lock은 필요하지 않습니다.";
2729        els.kbBeatPreviewCaseHint.classList.remove("is-hidden");
2730      } else {
2731        els.kbBeatPreviewCaseHint.textContent = "";
2732        els.kbBeatPreviewCaseHint.classList.add("is-hidden");
2733      }
2734    }
2735    renderKbBeatPreviewHighlights(norm);
2736  }
2737
2738  function openKbBeatPreviewModal() {
2739    if (!els.kbBeatPreviewLayer) {
2740      beginKeyboardBeatRoundAfterPrep();
2741      return;
2742    }
2743    populateKbBeatPreviewModal();
2744    els.kbBeatPreviewLayer.classList.remove("is-hidden");
2745    els.kbBeatPreviewLayer.setAttribute("aria-hidden", "false");
2746    if (els.kbBeatPreviewStartBtn) els.kbBeatPreviewStartBtn.focus();
2747  }
2748
2749  function prepareKbBeatRound() {
2750    closeKbSettingsLayer();
2751    readDifficulty();
2752    resolveBeatKeys();
2753    buildLaneDom();
2754  }
2755
2756  function beginKeyboardBeatRoundAfterPrep() {
2757    if (!warnMissingPlayfield("beginKeyboardBeatRoundAfterPrep")) {
2758      showSetup();
2759      return;
2760    }
2761    prepareKbBeatRound();
2762    Sound.init();
2763    showGameUi();
2764    beatChrome.enterMobilePrepFromUserGesture();
2765    Sound.resume().then(function () {
2766      runCountdown(function () {
2767        if (!isKbLoggedIn() && typeof PlayLabGuest !== "undefined") {
2768          PlayLabGuest.incrementGuestStart("keyboard_beat");
2769        }
2770        state.phase = "playing";
2771        if (!beatChrome.isMobileKb()) beatChrome.activatePcPlayingChromeOnly();
2772        beatChrome.retryFullscreenMobile();
2773        resetRoundStats();
2774        state.endGameMeta = null;
2775        hideLevelClearOverlay();
2776        state.gameStartPerf = performance.now();
2777        if (els.boardFx) els.boardFx.querySelectorAll(".kb-center-judge, .kb-hit-burst").forEach(function (x) { x.remove(); });
2778        startCampaignLevel(1);
2779        startBeatMetronome();
2780        requestBeatFrame();
2781      });
2782    });
2783  }
2784
2785  function shouldAutostartBeatFromUrl() {
2786    try {
2787      var params = new URLSearchParams(window.location.search);
2788      var v = params.get("autostart");
2789      return v === "1" || v === "true" || v === "yes";
2790    } catch (eAu) {
2791      return false;
2792    }
2793  }
2794
2795  function canStartBeatGuest() {
2796    if (isKbLoggedIn()) return true;
2797    if (typeof PlayLabGuest !== "undefined" && !PlayLabGuest.canGuestStartAnother("keyboard_beat")) {
2798      PlayLabGuest.showGuestLoginModal({ variant: "daily_limit", game: "keyboard_beat" });
2799      return false;
2800    }
2801    return true;
2802  }
2803
2804  /** 인트로(시작) 화면만 표시 */
2805  function showBeatIntro() {
2806    showSetup();
2807  }
2808
2809  /** 인트로 숨김 — 게임 영역으로 전환 준비 */
2810  function hideBeatIntro() {
2811    closeKbBeatPreviewModal();
2812    closeKbSettingsLayer();
2813    if (els.setup) {
2814      els.setup.classList.add("is-hidden");
2815      els.setup.setAttribute("aria-hidden", "true");
2816    }
2817    if (els.kbHeader) els.kbHeader.classList.add("is-hidden");
2818  }
2819
2820  /**
2821   * Keyboard Beat 게임 시작 (인트로·페이지 이동 없이 현재 DOM에서 바로 시작)
2822   * @param {object} [opts]
2823   * @param {boolean} [opts.skipPreview] — true면 키 프리뷰 모달 생략
2824   */
2825  function startKeyboardBeatGame(opts) {
2826    var options = opts || {};
2827    if (!canStartBeatGuest()) return;
2828    if (!warnMissingPlayfield("startKeyboardBeatGame")) {
2829      showBeatIntro();
2830      return;
2831    }
2832    hideBeatIntro();
2833    if (options.skipPreview !== false) {
2834      beginKeyboardBeatRoundAfterPrep();
2835      return;
2836    }
2837    openKbBeatPreviewModal();
2838  }
2839
2840  /** 프리뷰 모달 경유 시작 (설정·가이드용) */
2841  function kickoffKeyboardBeatRound() {
2842    if (!canStartBeatGuest()) return;
2843    hideBeatIntro();
2844    openKbBeatPreviewModal();
2845  }
2846
2847  function maybeAutostartBeatOnLoad() {
2848    if (!shouldAutostartBeatFromUrl()) return;
2849    if (!isPlayLabGameSolo()) return;
2850    window.setTimeout(function () {
2851      startKeyboardBeatGame({ skipPreview: true });
2852    }, 0);
2853  }
2854
2855  function endGame(meta) {
2856    if (state.phase === "ended") return;
2857    if (state.phase !== "playing" && state.phase !== "level_transition") return;
2858    hideLevelClearOverlay();
2859    if (state.levelClearTimerId) {
2860      clearTimeout(state.levelClearTimerId);
2861      state.levelClearTimerId = 0;
2862    }
2863    state.endGameMeta = meta || null;
2864    state.phase = "ended";
2865    cancelBeatFrame();
2866    clearInterval(state.beatTimerId);
2867    state.beatTimerId = 0;
2868    var finishAt = performance.now();
2869    if (!state.endReason) {
2870      state.endReason = meta && meta.reason === "accuracy_failed" ? "accuracy_failed" : "game_over";
2871    }
2872
2873    state.beatRankingSaveState = "idle";
2874    if (els.resultOverlay) {
2875      els.resultOverlay.classList.remove("kb-result--beat-level-clear");
2876    }
2877    hideBeatRankingSectionFull();
2878
2879    var denom = state.perfect + state.good + state.miss;
2880    var accNum = denom === 0 ? 0 : Math.round(((state.perfect + state.good) / denom) * 100);
2881    state.lastResultAccNum = accNum;
2882    var cleared = false;
2883    state.lastRoundCleared = false;
2884    BeatProgressStore.setSelectedLevel(state.selectedLevel);
2885    var acc = denom === 0 ? "—" : accNum + "%";
2886    if (els.resultScore) els.resultScore.textContent = String(state.score);
2887    if (els.resultMaxCombo) els.resultMaxCombo.textContent = String(state.maxCombo);
2888    if (els.resultAcc) els.resultAcc.textContent = acc;
2889    if (els.resultPerfect) els.resultPerfect.textContent = String(state.perfect);
2890    if (els.resultGood) els.resultGood.textContent = String(state.good);
2891    if (els.resultMiss) els.resultMiss.textContent = String(state.miss);
2892    var durationMs =
2893      state.gameStartPerf > 0 ? Math.max(0, Math.round(finishAt - state.gameStartPerf)) : 0;
2894    state.lastDurationMs = durationMs;
2895    var durationSec = Math.round(durationMs / 1000);
2896    if (els.resultDuration) els.resultDuration.textContent = durationSec + "초";
2897
2898    if (els.resultRankHint) {
2899      els.resultRankHint.classList.add("is-hidden");
2900      els.resultRankHint.textContent = "";
2901    }
2902    if (els.resultGuestNudge) els.resultGuestNudge.classList.add("is-hidden");
2903
2904    if (els.kbGuestResultActions) els.kbGuestResultActions.classList.toggle("is-hidden", isKbLoggedIn());
2905    if (els.kbLoggedResultActions) els.kbLoggedResultActions.classList.toggle("is-hidden", !isKbLoggedIn());
2906    syncKbResultPrimaryActionsLayout();
2907
2908    if (els.resultOverlay) els.resultOverlay.classList.remove("is-hidden");
2909    updateResultLevelUi(cleared);
2910    syncKbBeatResultFooterLayout(cleared);
2911    setBeatResultFooterVisible(true);
2912
2913    if (els.resultRecordNote) {
2914      els.resultRecordNote.textContent = "";
2915      els.resultRecordNote.classList.add("is-hidden");
2916    }
2917
2918    if (!isKbLoggedIn() && typeof PlayLabGuest !== "undefined") {
2919      var diff = readDifficultySlug();
2920      var keyMode = readKeySet();
2921      PlayLabGuest.writePendingResult({
2922        game_type: "keyboard_beat",
2923        score: state.score,
2924        accuracy: accNum,
2925        max_combo: state.maxCombo,
2926        perfect: state.perfect,
2927        good: state.good,
2928        miss: state.miss,
2929        difficulty: diff,
2930        key_mode: keyMode,
2931        bpm: state.bpm,
2932        duration_ms: durationMs,
2933        duration: durationMs,
2934        created_at: new Date().toISOString(),
2935        level: state.maxLevelReached,
2936        level_label:
2937          "LEVEL " + state.maxLevelReached + " · " + (state.levelConfig || getLevelConfig(state.currentLevel)).title,
2938      });
2939    }
2940
2941    function finishBeatPlayfieldCleanup() {
2942      if (els.touchRow) {
2943        els.touchRow.classList.add("is-hidden");
2944        els.touchRow.innerHTML = "";
2945      }
2946      clearPlayfieldNotes();
2947    }
2948
2949    fetchBeatRankingPositionAfterResult(accNum, cleared).then(function () {
2950      finishBeatPlayfieldCleanup();
2951    });
2952  }
2953
2954  function closeKbRankingLayer() {
2955    if (!els.kbBeatRankingLayer) return;
2956    els.kbBeatRankingLayer.classList.add("is-hidden");
2957    els.kbBeatRankingLayer.setAttribute("aria-hidden", "true");
2958    document.body.classList.remove("kb-beat-ranking-open");
2959  }
2960
2961  function openKbRankingLayer() {
2962    closeKbSettingsLayer();
2963    closeKbBeatPreviewModal();
2964    if (!els.kbBeatRankingLayer) return;
2965    els.kbBeatRankingLayer.classList.remove("is-hidden");
2966    els.kbBeatRankingLayer.setAttribute("aria-hidden", "false");
2967    document.body.classList.add("kb-beat-ranking-open");
2968    loadKbLeaderboard();
2969  }
2970
2971  function formatBeatDate(iso) {
2972    var s = iso && String(iso);
2973    if (!s) return "—";
2974    return s.length >= 10 ? s.slice(0, 10) : s;
2975  }
2976
2977  function loadKbLeaderboard() {
2978    if (!els.kbBeatRankingLoading || !els.kbBeatRankingEmpty || !els.kbBeatRankingTableWrap || !els.kbBeatRankingBody)
2979      return;
2980    els.kbBeatRankingLoading.classList.remove("is-hidden");
2981    els.kbBeatRankingEmpty.classList.add("is-hidden");
2982    els.kbBeatRankingTableWrap.classList.add("is-hidden");
2983    els.kbBeatRankingBody.innerHTML = "";
2984    fetch(KB_API_LEADERBOARD + "?limit=50", { credentials: "same-origin" })
2985      .then(function (res) {
2986        return res.json();
2987      })
2988      .then(function (data) {
2989        els.kbBeatRankingLoading.classList.add("is-hidden");
2990        if (!data || !data.success) {
2991          els.kbBeatRankingEmpty.textContent = "랭킹을 불러오지 못했습니다.";
2992          els.kbBeatRankingEmpty.classList.remove("is-hidden");
2993          return;
2994        }
2995        var rows = data.rankings || [];
2996        if (!rows.length) {
2997          els.kbBeatRankingEmpty.classList.remove("is-hidden");
2998          return;
2999        }
3000        els.kbBeatRankingTableWrap.classList.remove("is-hidden");
3001        rows.forEach(function (row) {
3002          var tr = document.createElement("tr");
3003          function td(txt) {
3004            var c = document.createElement("td");
3005            c.textContent = txt != null ? String(txt) : "";
3006            tr.appendChild(c);
3007          }
3008          td(row.rank);
3009          td(row.display_name || row.player_name || "—");
3010          td(row.score);
3011          td(row.max_combo);
3012          td(Math.round(Number(row.accuracy || 0) * 10) / 10 + "%");
3013          td(
3014            String(row.perfect_count != null ? row.perfect_count : 0) +
3015              " / " +
3016              String(row.good_count != null ? row.good_count : 0) +
3017              " / " +
3018              String(row.miss_count != null ? row.miss_count : 0)
3019          );
3020          td(String(row.difficulty || ""));
3021          td(String(row.bpm != null ? row.bpm : ""));
3022          td(keyModeLabel(String(row.key_mode || "")));
3023          td(formatBeatDate(row.created_at));
3024          els.kbBeatRankingBody.appendChild(tr);
3025        });
3026      })
3027      .catch(function () {
3028        els.kbBeatRankingLoading.classList.add("is-hidden");
3029        els.kbBeatRankingEmpty.textContent = "랭킹을 불러오지 못했습니다.";
3030        els.kbBeatRankingEmpty.classList.remove("is-hidden");
3031      });
3032  }
3033
3034  function stopAll() {
3035    setBeatPlayingLayout(false);
3036    beatChrome.teardown();
3037    hideLevelClearOverlay();
3038    if (state.levelClearTimerId) {
3039      clearTimeout(state.levelClearTimerId);
3040      state.levelClearTimerId = 0;
3041    }
3042    state.phase = "idle";
3043    cancelBeatFrame();
3044    clearInterval(state.beatTimerId);
3045    clearInterval(state.timeLeftId);
3046    state.beatTimerId = 0;
3047    state.timeLeftId = 0;
3048    if (els.notesLayer) els.notesLayer.querySelectorAll(".kb-note").forEach(function (x) { x.remove(); });
3049    else if (els.lane) els.lane.querySelectorAll(".kb-note").forEach(function (x) { x.remove(); });
3050    if (els.boardFx) els.boardFx.querySelectorAll(".kb-center-judge, .kb-hit-burst").forEach(function (x) { x.remove(); });
3051    if (els.resultOverlay) els.resultOverlay.classList.add("is-hidden");
3052    state.notes = [];
3053    closeKbBeatPreviewModal();
3054  }
3055
3056  /* -------------------------------------------------------------------------- */
3057  /* Touch row (mobile)                                                         */
3058  /* -------------------------------------------------------------------------- */
3059
3060  function refreshTouchRow() {
3061    if (!els.touchRow) return;
3062    var mobile = window.matchMedia("(max-width: 900px)").matches;
3063    if (!mobile || state.phase !== "playing") {
3064      els.touchRow.classList.add("is-hidden");
3065      els.touchRow.innerHTML = "";
3066      return;
3067    }
3068    els.touchRow.classList.remove("is-hidden");
3069    els.touchRow.innerHTML = "";
3070    var seen = {};
3071    state.keys.forEach(function (k) {
3072      if (seen[k]) return;
3073      seen[k] = true;
3074      var b = document.createElement("button");
3075      b.type = "button";
3076      b.className = "kb-touch";
3077      b.textContent = k;
3078      b.setAttribute("aria-label", "키 " + k);
3079      b.addEventListener("click", function () {
3080        handlePlayerInput(String(k), { source: "touch" });
3081      });
3082      els.touchRow.appendChild(b);
3083    });
3084    if (state.phase === "playing") {
3085      queueBeatPlayfieldLayoutSync(true, "refreshTouchRow");
3086    }
3087  }
3088
3089  var onResizeBound = function () {
3090    if (state.phase === "playing") {
3091      refreshTouchRow();
3092      queueBeatPlayfieldLayoutSync(true, "resize");
3093    }
3094  };
3095
3096  /* -------------------------------------------------------------------------- */
3097  /* Init                                                                       */
3098  /* -------------------------------------------------------------------------- */
3099
3100  function cacheEls() {
3101    els.root = document.getElementById("keyboardBeatRoot");
3102    if (!els.root) return false;
3103    els.setup = document.getElementById("kbSetup");
3104    els.game = document.getElementById("kbGame");
3105    els.resultOverlay = document.getElementById("kbResultOverlay");
3106    els.countdown = document.getElementById("kbCountdown");
3107    els.gameStack = document.getElementById("kbGameStack");
3108    els.playHud = document.getElementById("kbPlayHud");
3109    els.kbPlayAccuracy = document.getElementById("kbPlayAccuracy");
3110    els.kbPlayTargetAccuracy = document.getElementById("kbPlayTargetAccuracy");
3111    els.kbLevelTransition = document.getElementById("kbLevelTransition");
3112    els.kbLevelTransitionTitle = document.getElementById("kbLevelTransitionTitle");
3113    els.kbLevelTransitionSub = document.getElementById("kbLevelTransitionSub");
3114    els.playScore = document.getElementById("kbPlayScore");
3115    els.playTime = document.getElementById("kbPlayTime");
3116    els.playComboHud = document.getElementById("kbPlayComboHud");
3117    els.board = document.getElementById("kbBoard");
3118    els.boardFx = document.getElementById("kbBoardFx");
3119    els.comboDock = document.getElementById("kbComboDock");
3120    els.comboBig = document.getElementById("kbComboBig");
3121    els.lane = document.getElementById("kbLane");
3122    els.laneStage = document.getElementById("kbLaneStage");
3123    els.lanesBg = document.getElementById("kbLanesBg");
3124    els.notesLayer = document.getElementById("kbNotesLayer");
3125    els.laneLabels = document.getElementById("kbLaneLabels");
3126    els.hitLine = document.getElementById("kbHitLine");
3127    els.touchRow = document.getElementById("kbTouchRow");
3128    els.kbPlayLevelBrief = document.getElementById("kbPlayLevelBrief");
3129    els.keySet = document.getElementById("kbKeySet");
3130    els.kbSetupSummary = document.getElementById("kbSetupSummary");
3131    els.kbSetupSummaryKeys = document.getElementById("kbSetupSummaryKeys");
3132    els.kbStartLevelOneLine = document.getElementById("kbStartLevelOneLine");
3133    els.kbStartLevelKeys = document.getElementById("kbStartLevelKeys");
3134    els.kbStartLevelClear = document.getElementById("kbStartLevelClear");
3135    els.kbLayout = document.getElementById("kbLayout");
3136    els.kbHeader = document.querySelector("#keyboardBeatRoot .kb-header");
3137    els.kbLevelPicker = document.getElementById("kbLevelPicker");
3138    els.kbResultLevelBlock = document.getElementById("kbResultLevelBlock");
3139    els.kbResultLevelMsg = document.getElementById("kbResultLevelMsg");
3140    els.kbOpenSettingsBtn = document.getElementById("kbOpenSettingsBtn");
3141    els.kbMenuOpenSettingsBtn = document.getElementById("kbMenuOpenSettingsBtn");
3142    els.kbMenuOpenRankingBtn = document.getElementById("kbMenuOpenRankingBtn");
3143    els.kbMenuOpenGuideBtn = document.getElementById("kbMenuOpenGuideBtn");
3144    els.kbSettingsLayer = document.getElementById("kbSettingsLayer");
3145    els.kbSettingsBackdrop = document.getElementById("kbSettingsBackdrop");
3146    els.kbSettingsCloseBtn = document.getElementById("kbSettingsCloseBtn");
3147    els.kbSettingsConfirmBtn = document.getElementById("kbSettingsConfirmBtn");
3148    els.kbSettingsDismissBtn = document.getElementById("kbSettingsDismissBtn");
3149    els.kbSettingsCurrentKeysBrief = document.getElementById("kbSettingsCurrentKeysBrief");
3150    els.kbBeatPreviewLayer = document.getElementById("kbBeatPreviewLayer");
3151    els.kbBeatPreviewBackdrop = document.getElementById("kbBeatPreviewBackdrop");
3152    els.kbBeatPreviewLevelLine = document.getElementById("kbBeatPreviewLevelLine");
3153    els.kbBeatPreviewKeysLine = document.getElementById("kbBeatPreviewKeysLine");
3154    els.kbBeatPreviewCaseHint = document.getElementById("kbBeatPreviewCaseHint");
3155    els.kbBeatPreviewKeyboard = document.getElementById("kbBeatPreviewKeyboard");
3156    els.kbBeatPreviewCloseBtn = document.getElementById("kbBeatPreviewCloseBtn");
3157    els.kbBeatPreviewStartBtn = document.getElementById("kbBeatPreviewStartBtn");
3158    els.startBtn = document.getElementById("kbStartBtn");
3159    els.againBtn = document.getElementById("kbAgainBtn");
3160    els.resultTitle = document.getElementById("kbResultTitle");
3161    els.resultScore = document.getElementById("kbResultScore");
3162    els.resultEndReason = document.getElementById("kbResultEndReason");
3163    els.resultMaxCombo = document.getElementById("kbResultMaxCombo");
3164    els.resultAcc = document.getElementById("kbResultAcc");
3165    els.resultPerfect = document.getElementById("kbResultPerfect");
3166    els.resultGood = document.getElementById("kbResultGood");
3167    els.resultMiss = document.getElementById("kbResultMiss");
3168    els.resultRecordNote = document.getElementById("kbResultRecordNote");
3169    els.resultDuration = document.getElementById("kbResultDuration");
3170    els.resultRankHint = document.getElementById("kbResultRankHint");
3171    els.resultGuestNudge = document.getElementById("kbResultGuestNudge");
3172    els.kbResultRankingSection = document.getElementById("kbResultRankingSection");
3173    els.kbResultRankingTableWrap = document.getElementById("kbResultRankingTableWrap");
3174    els.kbResultRankingTableBody = document.getElementById("kbResultRankingTableBody");
3175    els.kbResultRankingHeading = document.getElementById("kbResultRankingHeading");
3176    els.kbResultRankNotice = document.getElementById("kbResultRankNotice");
3177    els.kbResultRankingSaveForm = document.getElementById("kbResultRankingSaveForm");
3178    els.kbResultRankingPrompt = document.getElementById("kbResultRankingPrompt");
3179    els.kbResultNameInput = document.getElementById("kbResultNameInput");
3180    els.kbResultNameError = document.getElementById("kbResultNameError");
3181    els.kbResultSaveRankBtn = document.getElementById("kbResultSaveRankBtn");
3182    els.kbResultRankingSavedStatus = document.getElementById("kbResultRankingSavedStatus");
3183    els.kbResultRankingRetryBtn = document.getElementById("kbResultRankingRetryBtn");
3184    els.kbGuestResultActions = document.getElementById("kbGuestResultActions");
3185    els.kbLoggedResultActions = document.getElementById("kbLoggedResultActions");
3186    els.kbGuestAgainBtn = document.getElementById("kbGuestAgainBtn");
3187    els.kbGuestToSetupBtn = document.getElementById("kbGuestToSetupBtn");
3188    els.kbGuestResultRankingBtn = document.getElementById("kbGuestResultRankingBtn");
3189    els.kbGuestResultRankingLink = document.getElementById("kbGuestResultRankingLink");
3190    els.kbLoggedResultRankingBtn = document.getElementById("kbLoggedResultRankingBtn");
3191    els.kbLoggedResultRankingLink = document.getElementById("kbLoggedResultRankingLink");
3192    els.kbOpenRankingBtn = document.getElementById("kbOpenRankingBtn");
3193    els.kbBeatRankingLayer = document.getElementById("kbBeatRankingLayer");
3194    els.kbBeatRankingBackdrop = document.getElementById("kbBeatRankingBackdrop");
3195    els.kbBeatRankingCloseBtn = document.getElementById("kbBeatRankingCloseBtn");
3196    els.kbBeatRankingLoading = document.getElementById("kbBeatRankingLoading");
3197    els.kbBeatRankingEmpty = document.getElementById("kbBeatRankingEmpty");
3198    els.kbBeatRankingTableWrap = document.getElementById("kbBeatRankingTableWrap");
3199    els.kbBeatRankingBody = document.getElementById("kbBeatRankingBody");
3200    els.kbNextLevelBtns = els.root.querySelectorAll(".js-kb-next-level");
3201    return true;
3202  }
3203
3204  function isPlayLabGameSolo() {
3205    return typeof PlayLabRoutes !== "undefined" && PlayLabRoutes.isPlayLabGameSolo();
3206  }
3207
3208  /** DOM 없을 때만 사용 — autostart=1 로 게임 전용 URL 이동 */
3209  function navigateToDedicatedBeatPlay() {
3210    var url = "/play-lab/keyboard-beat";
3211    if (typeof PlayLabRoutes !== "undefined") {
3212      if (PlayLabRoutes.getPlayLabGameContext && PlayLabRoutes.getPlayLabGameContext() === "app") {
3213        url = (PlayLabRoutes.getAppPlayUrl && PlayLabRoutes.getAppPlayUrl("keyboard-beat")) || url;
3214      } else if (PlayLabRoutes.getWebPlayUrl) {
3215        url = PlayLabRoutes.getWebPlayUrl("keyboard-beat") || url;
3216      }
3217      if (PlayLabRoutes.appendUrlQueryParam) {
3218        url = PlayLabRoutes.appendUrlQueryParam(url, "autostart", "1");
3219      } else {
3220        url += (url.indexOf("?") >= 0 ? "&" : "?") + "autostart=1";
3221      }
3222      window.location.assign(url);
3223      return;
3224    }
3225    window.location.assign(url + (url.indexOf("?") >= 0 ? "&" : "?") + "autostart=1");
3226  }
3227
3228  function replayFromResult() {
3229    startKeyboardBeatGame({ skipPreview: true });
3230  }
3231
3232  function onBeatStartBtnClick(ev) {
3233    if (ev) {
3234      ev.preventDefault();
3235      ev.stopPropagation();
3236    }
3237    if (!els.root || !document.getElementById("keyboardBeatRoot")) {
3238      navigateToDedicatedBeatPlay();
3239      return;
3240    }
3241    startKeyboardBeatGame({ skipPreview: true });
3242  }
3243
3244  function bindKeyboardBeatEvents() {
3245    var beatAutostart = shouldAutostartBeatFromUrl() && isPlayLabGameSolo();
3246    try {
3247      syncBeatProgressFromStore();
3248    } catch (eSync) {
3249      console.warn("[Keyboard Beat] syncBeatProgressFromStore failed", eSync);
3250    }
3251    try {
3252    if (els.startBtn && els.startBtn.getAttribute("data-kb-start-bound") !== "1") {
3253      els.startBtn.setAttribute("data-kb-start-bound", "1");
3254      bindClick(els.startBtn, onBeatStartBtnClick, "startButton");
3255    } else if (!els.startBtn) {
3256      console.warn("[Keyboard Beat] start button missing (#kbStartBtn)");
3257    }
3258    function bindKbBeatPreviewDismiss(el) {
3259      if (!el) return;
3260      el.addEventListener("click", function () {
3261        closeKbBeatPreviewModal();
3262      });
3263    }
3264    bindKbBeatPreviewDismiss(els.kbBeatPreviewCloseBtn);
3265    bindKbBeatPreviewDismiss(els.kbBeatPreviewBackdrop);
3266    if (els.kbBeatPreviewStartBtn) {
3267      els.kbBeatPreviewStartBtn.addEventListener("click", function () {
3268        closeKbBeatPreviewModal();
3269        beginKeyboardBeatRoundAfterPrep();
3270      });
3271    }
3272    if (els.againBtn) els.againBtn.addEventListener("click", replayFromResult);
3273    if (els.kbGuestAgainBtn) els.kbGuestAgainBtn.addEventListener("click", replayFromResult);
3274    if (els.kbGuestToSetupBtn) {
3275      els.kbGuestToSetupBtn.addEventListener("click", function () {
3276        showSetup();
3277      });
3278    }
3279    if (els.kbResultRankingRetryBtn) {
3280      els.kbResultRankingRetryBtn.addEventListener("click", function () {
3281        submitBeatRanking(state.lastResultAccNum);
3282      });
3283    }
3284    if (els.kbResultSaveRankBtn) {
3285      els.kbResultSaveRankBtn.addEventListener("click", function () {
3286        submitBeatRanking(state.lastResultAccNum);
3287      });
3288    }
3289    if (els.kbResultNameInput) {
3290      els.kbResultNameInput.addEventListener("input", function () {
3291        els.kbResultNameInput.value = normalizeArcadeName(els.kbResultNameInput.value);
3292        if (
3293          els.kbResultNameError &&
3294          els.kbResultNameInput.value &&
3295          validateNicknameClient(els.kbResultNameInput.value)
3296        ) {
3297          els.kbResultNameError.classList.add("is-hidden");
3298          els.kbResultNameError.textContent = "";
3299        }
3300      });
3301      els.kbResultNameInput.addEventListener("keydown", function (ev) {
3302        if (ev.key === "Enter") {
3303          ev.preventDefault();
3304          submitBeatRanking(state.lastResultAccNum);
3305        }
3306      });
3307    }
3308    function bindOpenRanking(el) {
3309      if (!el) return;
3310      el.addEventListener("click", function () {
3311        openKbRankingLayer();
3312      });
3313    }
3314    bindOpenRanking(els.kbOpenRankingBtn);
3315    bindOpenRanking(els.kbMenuOpenRankingBtn);
3316    bindOpenRanking(els.kbGuestResultRankingBtn);
3317    bindOpenRanking(els.kbGuestResultRankingLink);
3318    bindOpenRanking(els.kbLoggedResultRankingBtn);
3319    bindOpenRanking(els.kbLoggedResultRankingLink);
3320    if (els.kbOpenSettingsBtn) {
3321      els.kbOpenSettingsBtn.addEventListener("click", function () {
3322        openKbSettingsLayer();
3323      });
3324    }
3325    if (els.kbMenuOpenSettingsBtn) {
3326      els.kbMenuOpenSettingsBtn.addEventListener("click", function () {
3327        openKbSettingsLayer();
3328      });
3329    }
3330    if (els.kbMenuOpenGuideBtn) {
3331      els.kbMenuOpenGuideBtn.addEventListener("click", function () {
3332        openKbBeatPreviewModal();
3333      });
3334    }
3335    function bindCloseKbSettings(el) {
3336      if (!el) return;
3337      el.addEventListener("click", function () {
3338        closeKbSettingsLayer();
3339      });
3340    }
3341    bindCloseKbSettings(els.kbSettingsCloseBtn);
3342    bindCloseKbSettings(els.kbSettingsConfirmBtn);
3343    bindCloseKbSettings(els.kbSettingsDismissBtn);
3344    if (els.kbSettingsBackdrop) {
3345      els.kbSettingsBackdrop.addEventListener("click", function () {
3346        closeKbSettingsLayer();
3347      });
3348    }
3349    if (els.root) {
3350      els.root.addEventListener("change", function (ev) {
3351        var t = ev.target;
3352        if (!t) return;
3353        if (t.name === "kbDifficulty") updateSetupSummary();
3354      });
3355    }
3356    if (els.kbLevelPicker) {
3357      els.kbLevelPicker.addEventListener("click", function (ev) {
3358        var btn = ev.target && ev.target.closest ? ev.target.closest("[data-beat-level]") : null;
3359        if (!btn || btn.disabled) return;
3360        var lvl = parseInt(btn.getAttribute("data-beat-level"), 10);
3361        if (isNaN(lvl) || lvl < 1) return;
3362        if (lvl > state.unlockedLevel) return;
3363        state.selectedLevel = BeatProgressStore._clampLevel(lvl);
3364        BeatProgressStore.setSelectedLevel(state.selectedLevel);
3365        resolveBeatKeys();
3366        syncLevelPicker();
3367        updateSetupSummary();
3368        updateStartLevelCard();
3369      });
3370    }
3371    if (els.kbNextLevelBtns && els.kbNextLevelBtns.length) {
3372      for (var nxi = 0; nxi < els.kbNextLevelBtns.length; nxi++) {
3373        els.kbNextLevelBtns[nxi].addEventListener("click", goToNextLevelFromResult);
3374      }
3375    }
3376    if (els.kbBeatRankingCloseBtn)
3376 {
3377      els.kbBeatRankingCloseBtn.addEventListener("click", function () {
3378        closeKbRankingLayer();
3379      });
3380    }
3381    if (els.kbBeatRankingBackdrop) {
3382      els.kbBeatRankingBackdrop.addEventListener("click", function () {
3383        closeKbRankingLayer();
3384      });
3385    }
3386    document.addEventListener("keydown", function (ev) {
3387      if (ev.key !== "Escape") return;
3388      if (els.kbBeatRankingLayer && !els.kbBeatRankingLayer.classList.contains("is-hidden")) {
3389        closeKbRankingLayer();
3390        return;
3391      }
3392      if (els.kbBeatPreviewLayer && !els.kbBeatPreviewLayer.classList.contains("is-hidden")) {
3393        closeKbBeatPreviewModal();
3394        return;
3395      }
3396      if (els.kbSettingsLayer && !els.kbSettingsLayer.classList.contains("is-hidden")) {
3397        closeKbSettingsLayer();
3398      }
3399    });
3400    window.addEventListener("keydown", onKeyDown);
3401    window.addEventListener("resize", onResizeBound);
3402    } catch (eBind) {
3403      console.error("[Keyboard Beat] event bind failed", eBind);
3404    } finally {
3405      if (!isBeatPanelActive()) {
3406        parkBeatPanelUi();
3407      } else if (beatAutostart) {
3408        maybeAutostartBeatOnLoad();
3409      } else {
3410        try {
3411          showBeatIntro();
3412        } catch (eIntro) {
3413          console.error("[Keyboard Beat] showBeatIntro failed", eIntro);
3414        }
3415      }
3416    }
3417    if (typeof PlayLabGuest !== "undefined" && typeof PlayLabGuest.tryFlushPendingResults === "function") {
3418      try {
3419        PlayLabGuest.tryFlushPendingResults();
3420      } catch (ePl) {
3421        /* ignore */
3422      }
3423    }
3424  }
3425
3426  function initKeyboardBeat() {
3427    logBeatDomSnapshot("init start");
3428    try {
3429      if (!cacheEls()) {
3430        console.warn("[Keyboard Beat] #keyboardBeatRoot not found — init skipped");
3431        return;
3432      }
3433      bindKeyboardBeatEvents();
3434      logBeatDomSnapshot("init complete");
3435    } catch (errInit) {
3436      console.error("[Keyboard Beat] init failed", errInit);
3437      try {
3438        cacheEls();
3439        showSetup();
3440        logBeatDomSnapshot("init recovered");
3441      } catch (errRecover) {
3442        console.error("[Keyboard Beat] recovery failed", errRecover);
3443      }
3444    }
3445  }
3446
3447  function bootKeyboardBeatPage() {
3448    if (!document.getElementById("keyboardBeatRoot")) {
3449      return;
3450    }
3451    onBeatViewportChange();
3452    window.addEventListener("resize", onBeatViewportChange);
3453    window.addEventListener("orientationchange", onBeatViewportChange);
3454    if (window.visualViewport) {
3455      window.visualViewport.addEventListener("resize", onBeatViewportChange);
3456      window.visualViewport.addEventListener("scroll", onBeatViewportChange);
3457    }
3458    logBeatDomSnapshot("DOM ready");
3459    initKeyboardBeat();
3460  }
3461
3462  function onPlayLabPanelChange(panelId) {
3463    if (!els.root && !document.getElementById("keyboardBeatRoot")) return;
3464    if (!cacheEls() && !document.getElementById("keyboardBeatRoot")) return;
3465    if (panelId !== "keyboard-beat") {
3466      stopAll();
3467      parkBeatPanelUi();
3468      return;
3469    }
3470    if (state.phase === "playing" || state.phase === "level_transition") return;
3471    showSetup();
3472  }
3473
3474  if (document.readyState === "loading") {
3475    document.addEventListener("DOMContentLoaded", bootKeyboardBeatPage);
3476  } else {
3477    bootKeyboardBeatPage();
3478  }
3479
3480  window.KeyboardBeat = {
3481    onPlayLabPanelChange: onPlayLabPanelChange,
3482    showSetup: showSetup,
3483    showBeatIntro: showBeatIntro,
3484    hideBeatIntro: hideBeatIntro,
3485    startKeyboardBeatGame: startKeyboardBeatGame,
3486    init: initKeyboardBeat,
3487  };
3488})();

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.