05리듬 탭
세 줄로 노트가 떨어지고 판정선을 지나는 순간 키를 누르면 판정이 갈리며, 노트 애니메이션의 시계를 읽어 채점한다.
쓰임키보드 연습, 대기 화면의 손가락 놀이
animation-play-stategetAnimationstranslateY

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="rtl-note-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 164a28 28 0 1 1-28-28a28 28 0 0 1 28 28m-156 4a28 28 0 1 0 28 28a28 28 0 0 0-28-28" opacity=".2"/><path d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/></g></symbol>
<symbol id="rtl-trophy-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M200 48v63.1c0 39.7-31.75 72.6-71.45 72.9A72 72 0 0 1 56 112V48Z" opacity=".2"/><path d="M232 64h-24V48a8 8 0 0 0-8-8H56a8 8 0 0 0-8 8v16H24A16 16 0 0 0 8 80v16a40 40 0 0 0 40 40h3.65A80.13 80.13 0 0 0 120 191.61V216H96a8 8 0 0 0 0 16h64a8 8 0 0 0 0-16h-24v-24.42c31.94-3.23 58.44-25.64 68.08-55.58H208a40 40 0 0 0 40-40V80a16 16 0 0 0-16-16M48 120a24 24 0 0 1-24-24V80h24v32q0 4 .39 8Zm144-8.9c0 35.52-29 64.64-64 64.9a64 64 0 0 1-64-64V56h128ZM232 96a24 24 0 0 1-24 24h-.5a82 82 0 0 0 .5-8.9V80h24Z"/></g></symbol>
<symbol id="rtl-restart-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="rtl is-demo" id="rtl">
<header class="rtl__bar">
<p class="rtl__title">
<svg class="rtl__ico" aria-hidden="true"><use href="#rtl-note-ico"/></svg>
<span>리듬 탭</span>
</p>
<p class="rtl__pill"><span class="rtl__label">점수</span><b class="rtl__num" id="scoreNum">0</b></p>
<p class="rtl__pill"><span class="rtl__label">놓침</span><b class="rtl__num" id="missNum">0</b></p>
<p class="rtl__pill rtl__pill--best">
<svg class="rtl__ico rtl__ico--sm" aria-hidden="true"><use href="#rtl-trophy-ico"/></svg>
<b class="rtl__num" id="bestNum">0</b>
</p>
<button class="rtl__btn" id="restartBtn" type="button" aria-label="다시 시작">
<svg class="rtl__ico rtl__ico--sm" aria-hidden="true"><use href="#rtl-restart-ico"/></svg>
</button>
</header>
<div class="rtl__board" id="board" aria-label="리듬 탭 놀이판">
<div class="rtl__lane" data-lane="0" style="--k:0">
<div class="rtl__well" id="well0">
<span class="rtl__pv" style="--d:0ms"></span>
<span class="rtl__pv" style="--d:-1000ms"></span>
</div>
<span class="rtl__key">J</span>
</div>
<div class="rtl__lane" data-lane="1" style="--k:1">
<div class="rtl__well" id="well1">
<span class="rtl__pv" style="--d:-333ms"></span>
<span class="rtl__pv" style="--d:-1333ms"></span>
</div>
<span class="rtl__key">K</span>
</div>
<div class="rtl__lane" data-lane="2" style="--k:2">
<div class="rtl__well" id="well2">
<span class="rtl__pv" style="--d:-666ms"></span>
<span class="rtl__pv" style="--d:-1666ms"></span>
</div>
<span class="rtl__key">L</span>
</div>
<span class="rtl__line" aria-hidden="true"></span>
<p class="rtl__combo" id="combo" hidden>0 콤보</p>
<p class="rtl__verdict" id="verdict" aria-live="polite"></p>
<div class="rtl__start" id="startOver">
<p class="rtl__startTitle">J K L 키로 노트를 받아요</p>
<p class="rtl__startSub">1 2 3 으로도 받을 수 있어요 · 판정선에 노트가 닿는 순간이 퍼펙트입니다</p>
</div>
<div class="rtl__result" id="resultOver" hidden>
<p class="rtl__resultTitle">곡 끝!</p>
<p class="rtl__resultLine" id="resultLine">점수 0 · 최대 콤보 0 · 놓침 0</p>
<button class="rtl__again" id="againBtn" type="button">
<svg class="rtl__ico rtl__ico--sm" aria-hidden="true"><use href="#rtl-restart-ico"/></svg>
<span>다시 시작</span>
</button>
</div>
</div>
<p class="rtl__hint">퍼펙트 100점 · 좋음 60점 · 콤보가 이어지면 보너스가 붙어요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 리듬 탭 — 세 레인으로 노트가 translateY 로 낙하하고, 키(J·K·L / 1·2·3)를 누른 순간
// 노트 애니메이션의 시계(getAnimations 의 localTime)와 도착 시점(duration)의 차이로 판정이 갈린다.
// 명중한 노트는 animation-play-state: paused 로 그 자리에 멈춰 튀어오르고, 놓친 노트는 스르르 사라진다.
// 격자 미리보기(.is-demo)는 미리 떨어뜨려 둔 노트와 판정 글자가 도는 연출이다 — 실제 채점은 .is-demo 없이만.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-mint;
// 낙하 거리 — 판정선은 우물 아랫변. 노트는 정확히 애니메이션 끝(100%)에서 판정선 중앙에 온다
$wellH: clamp(64px, 40vh, 140px);
@include stage($stage-ink);
.rtl {
display: flex; flex-direction: column; justify-content: center; gap: $sp-2;
width: 100%; max-width: 440px;
height: calc(100vh - clamp(16px, 8vw, 48px));
}
// ── 스코어보드 ──────────────────────────────────────────────
.rtl__bar { display: flex; align-items: center; gap: $sp-1; flex-wrap: nowrap; }
.rtl__title {
display: flex; align-items: center; gap: $sp-1; margin: 0; margin-right: auto;
font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.2; color: $subject-cream;
}
.rtl__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.rtl__ico--sm { width: 12px; height: 12px; }
.rtl__pill {
display: flex; align-items: center; gap: $sp-1; margin: 0;
border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($subject-cream, .1);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2; color: $subject-cream;
white-space: nowrap;
}
.rtl__label { font-weight: 600; color: rgba($subject-cream, .72); }
.rtl__num { font-weight: 800; }
.rtl__btn {
display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto;
border: 0; border-radius: $r-xs; padding: 0; background: rgba($subject-cream, .12); cursor: pointer;
color: $subject-cream;
}
.rtl__btn:hover { background: rgba($subject-cream, .2); }
.rtl__btn:focus-visible { outline: 2px solid $color; outline-offset: 0; }
// ── 놀이판 ──────────────────────────────────────────────────
.rtl__board {
position: relative; display: flex; gap: $sp-1;
border-radius: $r-card; padding: $sp-2; background: rgba($subject-cream, .05); overflow: hidden;
}
.rtl__lane { flex: 1 1 0; display: flex; flex-direction: column; gap: $sp-1; min-width: 0; }
.rtl__well {
position: relative; height: $wellH; overflow: hidden;
border-radius: $r-sm; background: rgba($subject-cream, .06);
--fall-h: calc(#{$wellH} - 6px);
}
.rtl__key {
display: grid; place-items: center; height: 20px;
border-radius: $r-xs; background: rgba($subject-cream, .12);
font-weight: 700; font-size: 10px; line-height: 1; color: $subject-cream;
}
// 판정선 — 우물 아랫변(칸 여백 8px + 키칩 20px + 간격 4px)에 정확히 닿는다
.rtl__line {
position: absolute; left: $sp-2; right: $sp-2; bottom: 32px; height: 2px;
background: rgba($subject-cream, .8); box-shadow: 0 0 8px rgba($subject-cream, .4);
}
// ── 노트 ────────────────────────────────────────────────────
// 노트 몸통은 ::before 다 — 낙하(transform)와 명중 팝(transform)이 서로를 덮어쓰지 않게 분리했다
.rtl__note, .rtl__pv {
position: absolute; left: 50%; top: 0; width: 62%; height: 14px; margin-left: -31%;
}
.rtl__note::before, .rtl__pv::before {
content: ""; position: absolute; inset: 0; border-radius: $r-pill; background: $color;
}
.rtl__note {
animation-name: rtlFall; animation-duration: $duration; animation-timing-function: linear;
animation-iteration-count: 1; animation-fill-mode: both;
}
// 명중 — 낙하를 그 자리에 멈추고(판정 순간의 위치) 알맹이가 자기 중심에서 튀어오른다
.rtl__note.is-hit { animation-play-state: paused; }
.rtl__note.is-hit::before {
animation-name: rtlPop; animation-duration: 240ms; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: forwards;
}
// 놓침 — 낙하는 계속 두고 알맹이만 스르르 사라진다
.rtl__note.is-miss::before {
animation-name: rtlFade; animation-duration: 320ms; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: forwards;
}
// reduce 점등형 — 애니메이션이 없을 때 due 순간에 판정선으로 바로 옮겨 점등한다
.rtl__note.is-due { transform: translateY(var(--fall-h)); }
@keyframes rtlFall {
from { transform: translateY(-16px); }
to { transform: translateY(var(--fall-h)); }
}
@keyframes rtlPop {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.8); opacity: 0; }
}
@keyframes rtlFade {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(8px); opacity: .15; }
}
// ── 판정 글자 · 콤보 ────────────────────────────────────────
.rtl__verdict {
position: absolute; left: 0; right: 0; bottom: 40px; margin: 0; text-align: center;
font-weight: 800; font-size: clamp(12px, 3.2vw, 15px); line-height: 1.2; letter-spacing: .04em;
opacity: 0; pointer-events: none;
}
.rtl__verdict.is-perfect { color: $subject-mint; }
.rtl__verdict.is-good { color: $subject-blue-light; }
.rtl__verdict.is-miss { color: $error-soft; }
.rtl__verdict.is-show {
animation-name: rtlVerdict; animation-duration: 900ms; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: forwards;
}
@keyframes rtlVerdict {
0% { opacity: 0; transform: translateY(6px) scale(.9); }
12% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(0) scale(1); }
}
.rtl__combo {
position: absolute; left: 0; right: 0; bottom: 64px; margin: 0; text-align: center;
font-weight: 800; font-size: clamp(10px, 2.8vw, 13px); line-height: 1.2; color: $subject-cream;
opacity: .92; pointer-events: none;
}
.rtl__combo.is-bump {
animation-name: rtlBump; animation-duration: 300ms; animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes rtlBump {
0% { transform: scale(1); }
40% { transform: scale(1.18); }
100% { transform: scale(1); }
}
// ── 시작 · 결과 덮개 ────────────────────────────────────────
// display 를 직접 주는 규칙은 hidden 속성(UA display:none)을 이겨버린다 — 덮개가 사라지지 않는다
.rtl [hidden] { display: none !important; }
.rtl__start, .rtl__result {
position: absolute; inset: 0; display: grid; place-content: center; gap: $sp-2;
background: rgba($stage-ink, .8); text-align: center; padding: $sp-3;
}
.rtl__startTitle, .rtl__resultTitle {
margin: 0; font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.3; color: $subject-cream;
}
.rtl__startSub {
margin: 0; font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .72);
}
.rtl__resultLine {
margin: 0; font-weight: 600; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.5; color: rgba($subject-cream, .72);
}
.rtl__again {
justify-self: center; display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-control; padding: $sp-1 $sp-3; background: $subject-blue-deep;
color: $subject-cream; font-family: inherit; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4; cursor: pointer;
}
.rtl__again:hover { background: $subject-blue; }
.rtl__again:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.rtl__hint {
margin: 0; font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
color: rgba($subject-cream, .72);
}
// ── 격자 미리보기 ───────────────────────────────────────────
// 미리 떨어지는 노트 6개(레인마다 2개, 지연을 흩뿌려 두었다)와 키칩 순차 점멸이 돈다
.rtl__pv {
animation-name: rtlFall; animation-duration: $duration; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: var(--d);
}
.rtl:not(.is-demo) .rtl__pv { display: none; }
.rtl.is-demo .rtl__start { display: none; }
.rtl.is-demo .rtl__key {
animation-name: rtlKeyPulse; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--k) * 666ms);
}
@keyframes rtlKeyPulse {
0%, 100% { opacity: .78; transform: scale(1); }
15% { opacity: 1; transform: scale(1.05); }
30% { opacity: .78; transform: scale(1); }
}
@media (max-width: 340px) {
.rtl__well { height: 64px; --fall-h: 58px; }
.rtl__combo { bottom: 56px; }
}
// reduce — 노트는 낙하 대신 due 순간 판정선에 점등하고(is-due), 판정은 글자로만 나간다
@media (prefers-reduced-motion: reduce) {
.rtl__note, .rtl__pv, .rtl__key { animation: none !important; }
.rtl__note.is-hit::before, .rtl__note.is-miss::before { animation: none !important; }
.rtl__pv { display: none !important; }
.rtl__note { opacity: 0; }
.rtl__note.is-due, .rtl__note.is-hit, .rtl__note.is-miss { opacity: 1; }
.rtl__verdict.is-show { animation: none !important; opacity: 1 !important; }
.rtl__combo.is-bump { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rtl {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
width: 100%;
max-width: 440px;
height: calc(100vh - clamp(16px, 8vw, 48px));
}
.rtl__bar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.rtl__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
margin-right: auto;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.2;
color: #fff7e6;
}
.rtl__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #4cd4a6;
}
.rtl__ico--sm {
width: 12px;
height: 12px;
}
.rtl__pill {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.1);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
color: #fff7e6;
white-space: nowrap;
}
.rtl__label {
font-weight: 600;
color: rgba(255, 247, 230, 0.72);
}
.rtl__num {
font-weight: 800;
}
.rtl__btn {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 0;
border-radius: 4px;
padding: 0;
background: rgba(255, 247, 230, 0.12);
cursor: pointer;
color: #fff7e6;
}
.rtl__btn:hover {
background: rgba(255, 247, 230, 0.2);
}
.rtl__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 0;
}
.rtl__board {
position: relative;
display: flex;
gap: 4px;
border-radius: 16px;
padding: 8px;
background: rgba(255, 247, 230, 0.05);
overflow: hidden;
}
.rtl__lane {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rtl__well {
position: relative;
height: clamp(64px, 40vh, 140px);
overflow: hidden;
border-radius: 8px;
background: rgba(255, 247, 230, 0.06);
--fall-h: calc(clamp(64px, 40vh, 140px) - 6px);
}
.rtl__key {
display: grid;
place-items: center;
height: 20px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
font-weight: 700;
font-size: 10px;
line-height: 1;
color: #fff7e6;
}
.rtl__line {
position: absolute;
left: 8px;
right: 8px;
bottom: 32px;
height: 2px;
background: rgba(255, 247, 230, 0.8);
box-shadow: 0 0 8px rgba(255, 247, 230, 0.4);
}
.rtl__note, .rtl__pv {
position: absolute;
left: 50%;
top: 0;
width: 62%;
height: 14px;
margin-left: -31%;
}
.rtl__note::before, .rtl__pv::before {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: #4cd4a6;
}
.rtl__note {
animation-name: rtlFall;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: both;
}
.rtl__note.is-hit {
animation-play-state: paused;
}
.rtl__note.is-hit::before {
animation-name: rtlPop;
animation-duration: 240ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.rtl__note.is-miss::before {
animation-name: rtlFade;
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.rtl__note.is-due {
transform: translateY(var(--fall-h));
}
@keyframes rtlFall {
from {
transform: translateY(-16px);
}
to {
transform: translateY(var(--fall-h));
}
}
@keyframes rtlPop {
from {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(1.8);
opacity: 0;
}
}
@keyframes rtlFade {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(8px);
opacity: 0.15;
}
}
.rtl__verdict {
position: absolute;
left: 0;
right: 0;
bottom: 40px;
margin: 0;
text-align: center;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1.2;
letter-spacing: 0.04em;
opacity: 0;
pointer-events: none;
}
.rtl__verdict.is-perfect {
color: #4cd4a6;
}
.rtl__verdict.is-good {
color: #5b8cf8;
}
.rtl__verdict.is-miss {
color: #ffe0d8;
}
.rtl__verdict.is-show {
animation-name: rtlVerdict;
animation-duration: 900ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes rtlVerdict {
0% {
opacity: 0;
transform: translateY(6px) scale(0.9);
}
12% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(0) scale(1);
}
}
.rtl__combo {
position: absolute;
left: 0;
right: 0;
bottom: 64px;
margin: 0;
text-align: center;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 13px);
line-height: 1.2;
color: #fff7e6;
opacity: 0.92;
pointer-events: none;
}
.rtl__combo.is-bump {
animation-name: rtlBump;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes rtlBump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
.rtl [hidden] {
display: none !important;
}
.rtl__start, .rtl__result {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
background: rgba(23, 20, 26, 0.8);
text-align: center;
padding: 12px;
}
.rtl__startTitle, .rtl__resultTitle {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.3;
color: #fff7e6;
}
.rtl__startSub {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.72);
}
.rtl__resultLine {
margin: 0;
font-weight: 600;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.72);
}
.rtl__again {
justify-self: center;
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 12px;
padding: 4px 12px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
cursor: pointer;
}
.rtl__again:hover {
background: #2f6df6;
}
.rtl__again:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.rtl__hint {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.rtl__pv {
animation-name: rtlFall;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: var(--d);
}
.rtl:not(.is-demo) .rtl__pv {
display: none;
}
.rtl.is-demo .rtl__start {
display: none;
}
.rtl.is-demo .rtl__key {
animation-name: rtlKeyPulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: calc(var(--k) * 666ms);
}
@keyframes rtlKeyPulse {
0%, 100% {
opacity: 0.78;
transform: scale(1);
}
15% {
opacity: 1;
transform: scale(1.05);
}
30% {
opacity: 0.78;
transform: scale(1);
}
}
@media (max-width: 340px) {
.rtl__well {
height: 64px;
--fall-h: 58px;
}
.rtl__combo {
bottom: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.rtl__note, .rtl__pv, .rtl__key {
animation: none !important;
}
.rtl__note.is-hit::before, .rtl__note.is-miss::before {
animation: none !important;
}
.rtl__pv {
display: none !important;
}
.rtl__note {
opacity: 0;
}
.rtl__note.is-due, .rtl__note.is-hit, .rtl__note.is-miss {
opacity: 1;
}
.rtl__verdict.is-show {
animation: none !important;
opacity: 1 !important;
}
.rtl__combo.is-bump {
animation: none !important;
}
}var BEST_KEY = 'p301-05-best';
var VERDICT_PERFECT = '퍼펙트';
var VERDICT_GOOD = '좋음';
var VERDICT_MISS = '놓침';
var COMBO_FMT = '{n} 콤보';
var RESULT_FMT = '점수 {s} · 최대 콤보 {c} · 놓침 {m}';
var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 곡 스케줄 — [박수, 레인]. 박 하나는 480ms, 첫 노트는 5박(2400ms) 뒤에 판정선에 닿는다
var BEAT = 480;
var SONG = [[5,0],[6,1],[7,2],[9,1],[10,0],[12,2],[13,1],[14,0],[16,2],[17,1],[18,2],[20,0],[21,1],[22,2],[24,1],[25,0],[27,2],[28,1]];
// 판정 창(ms) — 노트 애니메이션의 시계(currentTime)와 도착 시점(duration)의 차이로 갈린다
var WIN_PERFECT = 115, WIN_GOOD = 240, WIN_MISS = 400;
var KEY_LANE = { j: 0, k: 1, l: 2, '1': 0, '2': 1, '3': 2 };
var root = document.getElementById('rtl');
var wells = [document.getElementById('well0'), document.getElementById('well1'), document.getElementById('well2')];
var scoreNum = document.getElementById('scoreNum');
var missNum = document.getElementById('missNum');
var bestNum = document.getElementById('bestNum');
var verdictEl = document.getElementById('verdict');
var comboEl = document.getElementById('combo');
var startOver = document.getElementById('startOver');
var resultOver = document.getElementById('resultOver');
var resultLine = document.getElementById('resultLine');
var st = { playing: false, notes: [], score: 0, combo: 0, maxCombo: 0, miss: 0,
fallMs: 2000, t0: 0, raf: 0, best: 0, demoTimer: 0 };
function loadBest() {
try { st.best = Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { st.best = 0; }
bestNum.textContent = String(st.best);
}
function saveBest() {
if (st.score <= st.best) { return; }
st.best = st.score;
try { window.localStorage.setItem(BEST_KEY, String(st.best)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
bestNum.textContent = String(st.best);
}
// 노트를 만들어 레인에 붙인다 — 낙하 CSS 애니메이션의 지연을 스케줄로 쓴다
function makeNote(tDue, lane) {
var n = document.createElement('span');
n.className = 'rtl__note';
n.setAttribute('data-lane', String(lane));
n.setAttribute('data-due', String(tDue));
wells[lane].appendChild(n);
return n;
}
// 노트의 낙하 애니메이션 — getAnimations 로 직접 잡는다 (애니메이션 시계가 곧 판정 기준이다)
function fallAnim(note) {
var anims = note.getAnimations();
for (var i = 0; i < anims.length; i++) {
if (anims[i].animationName === 'rtlFall') { return anims[i]; }
}
return anims[0] || null;
}
// 곡 시작부터 흐른 시각을 그 노트의 낙하 시간으로 환산한 벽시계 값
function wallNow(note) {
return (performance.now() - st.t0) - Number(note.getAttribute('data-due')) + st.fallMs;
}
// 판정에 쓰는 시각 — 노트가 실제로 그려진 위치가 곧 판정 기준이므로 낙하 애니메이션의
// 시계(currentTime 에서 delay 를 뺀 값 = 낙하가 시작된 뒤 흐른 시간)를 읽는다.
// 낙하가 끝난 애니메이션은 currentTime 이 끝점에 고정되므로(판정 창을 지나친 노트)
// 그때부터는 벽시계로 넘어간다 — 그래야 놓친 노트가 제때 정리된다.
function noteNow(note) {
if (REDUCE) { return wallNow(note); }
var a = fallAnim(note);
if (a && a.effect && a.playState !== 'finished') {
var ct = a.effect.getComputedTiming();
var delay = a.effect.getTiming().delay || 0;
if (ct.localTime != null) { return ct.localTime - delay; }
}
return wallNow(note);
}
function showVerdict(kind) {
var txt = kind === 'perfect' ? VERDICT_PERFECT : (kind === 'good' ? VERDICT_GOOD : VERDICT_MISS);
verdictEl.textContent = txt;
verdictEl.className = 'rtl__verdict is-' + kind;
// 애니메이션이 다시 켜지게 한 프레임 비운 뒤 킨다 — 같은 판정이 연달아 나와도 점등이 보인다
void verdictEl.offsetWidth;
verdictEl.classList.add('is-show');
}
function paintCombo() {
if (st.combo >= 2) {
comboEl.hidden = false;
comboEl.textContent = COMBO_FMT.replace('{n}', String(st.combo));
comboEl.classList.remove('is-bump');
void comboEl.offsetWidth;
comboEl.classList.add('is-bump');
} else {
comboEl.hidden = true;
}
}
function judgeHit(note, kind) {
note.classList.add(kind === 'miss' ? 'is-miss' : 'is-hit');
note.setAttribute('data-judged', '1');
if (kind === 'perfect') { st.combo += 1; st.score += 100 + st.combo * 5; }
else if (kind === 'good') { st.combo += 1; st.score += 60 + st.combo * 5; }
else { st.combo = 0; st.miss += 1; }
if (st.combo > st.maxCombo) { st.maxCombo = st.combo; }
scoreNum.textContent = String(st.score);
missNum.textContent = String(st.miss);
paintCombo();
showVerdict(kind);
window.setTimeout(function () { if (note.parentNode) { note.parentNode.removeChild(note); } }, 320);
}
function pressLane(lane) {
if (!st.playing) { start(); return; }
var best = null, bestDiff = Infinity;
for (var i = 0; i < st.notes.length; i++) {
var n = st.notes[i];
if (n.getAttribute('data-judged') || Number(n.getAttribute('data-lane')) !== lane) { continue; }
var diff = Math.abs(noteNow(n) - st.fallMs);
if (diff < bestDiff) { bestDiff = diff; best = n; }
}
if (!best || bestDiff > WIN_MISS) { return; }
if (bestDiff <= WIN_PERFECT) { judgeHit(best, 'perfect'); }
else if (bestDiff <= WIN_GOOD) { judgeHit(best, 'good'); }
else { judgeHit(best, 'miss'); }
}
// 놓친 노트(판정 창을 지나친 노트)를 치우고 곡이 끝났는지 본다
function tick() {
if (!st.playing) { return; }
var remain = 0;
for (var i = 0; i < st.notes.length; i++) {
var n = st.notes[i];
if (!n.getAttribute('data-judged')) {
remain += 1;
var cur = noteNow(n);
if (REDUCE && !n.classList.contains('is-due') && Math.abs(cur - st.fallMs) <= WIN_MISS) {
n.classList.add('is-due');
}
if (cur > st.fallMs + WIN_MISS) { judgeHit(n, 'miss'); remain -= 1; }
}
}
var lastDue = SONG[SONG.length - 1][0] * BEAT;
if (remain === 0 && performance.now() - st.t0 > lastDue + 900) { finish(); return; }
st.raf = window.requestAnimationFrame(tick);
}
function start() {
root.classList.remove('is-demo');
window.clearInterval(st.demoTimer);
clearNotes();
st.score = 0; st.combo = 0; st.maxCombo = 0; st.miss = 0;
scoreNum.textContent = '0'; missNum.textContent = '0';
comboEl.hidden = true; verdictEl.textContent = ''; verdictEl.className = 'rtl__verdict';
startOver.hidden = true; resultOver.hidden = true;
// 낙하 시간을 먼저 잰다 — 노트 지연(스케줄)이 이 값으로 계산된다
var probe = makeNote(0, 0);
void probe.offsetWidth;
var pa = fallAnim(probe);
st.fallMs = (pa && pa.effect && pa.effect.getTiming().duration) || 2000;
st.notes = [probe];
probe.setAttribute('data-due', String(SONG[0][0] * BEAT));
probe.style.animationDelay = String(SONG[0][0] * BEAT - st.fallMs) + 'ms';
for (var i = 1; i < SONG.length; i++) {
var tDue = SONG[i][0] * BEAT;
var n = makeNote(tDue, SONG[i][1]);
n.style.animationDelay = String(tDue - st.fallMs) + 'ms';
st.notes.push(n);
}
st.t0 = performance.now();
st.playing = true;
st.raf = window.requestAnimationFrame(tick);
}
function clearNotes() {
for (var i = 0; i < st.notes.length; i++) {
if (st.notes[i].parentNode) { st.notes[i].parentNode.removeChild(st.notes[i]); }
}
st.notes = [];
}
function finish() {
st.playing = false;
window.cancelAnimationFrame(st.raf);
saveBest();
resultLine.textContent = RESULT_FMT.replace('{s}', String(st.score)).replace('{c}', String(st.maxCombo)).replace('{m}', String(st.miss));
resultOver.hidden = false;
}
function toReady() {
st.playing = false;
window.cancelAnimationFrame(st.raf);
root.classList.remove('is-demo');
window.clearInterval(st.demoTimer);
clearNotes();
st.score = 0; st.combo = 0; st.maxCombo = 0; st.miss = 0;
scoreNum.textContent = '0'; missNum.textContent = '0';
comboEl.hidden = true; verdictEl.textContent = ''; verdictEl.className = 'rtl__verdict';
resultOver.hidden = true;
startOver.hidden = false;
}
window.addEventListener('keydown', function (e) {
var lane = KEY_LANE[e.key.toLowerCase()];
if (lane === undefined) { return; }
if (e.repeat) { return; }
pressLane(lane);
});
document.getElementById('board').addEventListener('click', function () {
if (!st.playing && startOver.hidden === false) { start(); }
});
document.getElementById('againBtn').addEventListener('click', function () { start(); });
document.getElementById('restartBtn').addEventListener('click', function () { toReady(); });
// 격자 미리보기 — 판정 글자와 콤보가 숨 쉬는 연출만 담당한다 (채점은 .is-demo 없이만)
function demoCycle() {
var kinds = ['perfect', 'good', 'perfect', 'miss'];
var combos = [4, 9, 16, 7];
var i = 0;
function step() {
// .is-demo 가 떼어지면(사람이 시작하면) 이 연출도 스스로 멈추고 남긴 글자를 치운다
if (!root.classList.contains('is-demo')) {
window.clearInterval(st.demoTimer);
if (!st.playing) { verdictEl.textContent = ''; verdictEl.className = 'rtl__verdict'; comboEl.hidden = true; }
return;
}
var k = kinds[i % 4];
verdictEl.textContent = k === 'perfect' ? VERDICT_PERFECT : (k === 'good' ? VERDICT_GOOD : VERDICT_MISS);
verdictEl.className = 'rtl__verdict is-show is-' + k;
comboEl.hidden = false;
comboEl.textContent = COMBO_FMT.replace('{n}', String(combos[i % 4]));
i += 1;
}
step();
st.demoTimer = window.setInterval(step, 700);
}
loadBest();
if (root.classList.contains('is-demo')) { demoCycle(); }// RhythmTapLane — vanilla 판(run/301/demo/05-rhythm-tap-lane) 의 React 포트.
// 노트는 CSS 애니메이션(translateY)으로 낙하하고, 키를 누른 순간 그 노트 애니메이션의 시계를
// getAnimations 로 읽어 판정한다. 명중한 노트는 animation-play-state: paused 로 제자리에 멈춘다.
// 노트 목록은 렌더 대상이라 state 로, 프레임마다 바뀌는 게임 시계는 ref 로 나눠 들었다.
// duration·easing·accentColor 는 props → CSS 변수로 들어온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RhythmTapLane.module.scss";
export type RhythmTapLaneProps = {
duration?: string;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; repeat: boolean };
type Box<T> = { current: T };
type Verdict = "perfect" | "good" | "miss";
type Note = { id: number; lane: number; due: number; judged: Verdict | null; due2: boolean };
type Phase = "demo" | "ready" | "play" | "done";
const BEST_KEY = "p301-05-best";
const VERDICT_PERFECT = "퍼펙트";
const VERDICT_GOOD = "좋음";
const VERDICT_MISS = "놓침";
const COMBO_FMT = "{n} 콤보";
const RESULT_FMT = "점수 {s} · 최대 콤보 {c} · 놓침 {m}";
const TITLE_TEXT = "리듬 탭";
const BOARD_LABEL = "리듬 탭 놀이판";
const SCORE_LABEL = "점수";
const MISS_LABEL = "놓침";
const AGAIN_LABEL = "다시 시작";
const DONE_TITLE = "곡 끝!";
const START_TITLE = "J K L 키로 노트를 받아요";
const START_SUB = "1 2 3 으로도 받을 수 있어요 · 판정선에 노트가 닿는 순간이 퍼펙트입니다";
const HINT_TEXT = "퍼펙트 100점 · 좋음 60점 · 콤보가 이어지면 보너스가 붙어요";
// 곡 스케줄 — [박수, 레인]. 박 하나는 480ms, 첫 노트는 5박(2400ms) 뒤에 판정선에 닿는다
const BEAT = 480;
const SONG: number[][] = [[5, 0], [6, 1], [7, 2], [9, 1], [10, 0], [12, 2], [13, 1], [14, 0], [16, 2],
[17, 1], [18, 2], [20, 0], [21, 1], [22, 2], [24, 1], [25, 0], [27, 2], [28, 1]];
// 판정 창(ms) — 노트 애니메이션의 시계와 도착 시점(duration)의 차이로 갈린다
const WIN_PERFECT = 115, WIN_GOOD = 240, WIN_MISS = 400;
const KEY_LANE: { [k: string]: number } = { j: 0, k: 1, l: 2, "1": 0, "2": 1, "3": 2 };
const LANE_KEYS = ["J", "K", "L"];
// 미리보기 노트 — 레인마다 2개, 지연을 흩뿌려 둔다
const PREVIEW: number[][] = [[0, 0], [0, -1000], [1, -333], [1, -1333], [2, -666], [2, -1666]];
function verdictText(v: Verdict): string {
return v === "perfect" ? VERDICT_PERFECT : (v === "good" ? VERDICT_GOOD : VERDICT_MISS);
}
export default function RhythmTapLane({ duration, easing, accentColor }: RhythmTapLaneProps) {
const [phase, setPhase] = useState<Phase>("demo");
const [notes, setNotes] = useState<Note[]>([]);
const [score, setScore] = useState(0);
const [miss, setMiss] = useState(0);
const [combo, setCombo] = useState(0);
const [maxCombo, setMaxCombo] = useState(0);
const [best, setBest] = useState(0);
const [verdict, setVerdict] = useState<{ v: Verdict; n: number } | null>(null);
const [demoVerdict, setDemoVerdict] = useState<{ v: Verdict; combo: number }>({ v: "perfect", combo: 4 });
const wellRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]) as Box<(HTMLDivElement | null)[]>;
const g = useRef({ t0: 0, fallMs: 2000, raf: 0, seq: 0, phase: "demo" as Phase, reduce: false }) as
Box<{ t0: number; fallMs: number; raf: number; seq: number; phase: Phase; reduce: boolean }>;
const notesRef = useRef<Note[]>([]) as Box<Note[]>;
const api = useRef<{ start(): void; ready(): void }>({ start() { /* 마운트 전 호출은 무시한다 */ }, ready() { /* 같다 */ } }) as
Box<{ start(): void; ready(): void }>;
useEffect(() => { notesRef.current = notes; }, [notes]);
useEffect(() => { g.current.phase = phase; }, [phase]);
useEffect(() => {
const st = g.current;
st.reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
try { setBest(Number(window.localStorage.getItem(BEST_KEY)) || 0); } catch (e) { setBest(0); }
}, []);
// 노트 DOM 의 낙하 애니메이션 — getAnimations 로 직접 잡는다 (애니메이션 시계가 곧 판정 기준이다)
const fallAnim = (el: Element): Animation | null => {
const list = el.getAnimations();
for (let i = 0; i < list.length; i++) {
const a = list[i] as Animation & { animationName?: string };
if (a.animationName === "rtlFall") { return a; }
}
return list[0] || null;
};
const wallNow = (n: Note): number => (performance.now() - g.current.t0) - n.due + g.current.fallMs;
// 판정에 쓰는 시각 — 낙하가 끝난 애니메이션은 currentTime 이 끝점에 고정되므로 그때부터 벽시계로 넘어간다
const noteNow = (n: Note): number => {
if (g.current.reduce) { return wallNow(n); }
const el = document.getElementById("rtl-note-" + n.id);
if (!el) { return wallNow(n); }
const a = fallAnim(el);
if (a && a.effect && a.playState !== "finished") {
const ct = a.effect.getComputedTiming();
const delay = (a.effect.getTiming().delay as number) || 0;
if (ct.localTime != null) { return (ct.localTime as number) - delay; }
}
return wallNow(n);
};
const judge = (id: number, v: Verdict) => {
setNotes((prev) => prev.map((n) => (n.id === id ? { ...n, judged: v } : n)));
if (v === "miss") {
setCombo(0);
setMiss((m) => m + 1);
} else {
setCombo((c) => {
const next = c + 1;
setMaxCombo((mx) => (next > mx ? next : mx));
setScore((s) => s + (v === "perfect" ? 100 : 60) + next * 5);
return next;
});
}
g.current.seq += 1;
setVerdict({ v, n: g.current.seq });
window.setTimeout(() => { setNotes((prev) => prev.filter((n) => n.id !== id)); }, 320);
};
const pressLane = (lane: number) => {
if (g.current.phase !== "play") {
if (g.current.phase === "demo" || g.current.phase === "ready") { api.current.start(); }
return;
}
let target: Note | null = null;
let bestDiff = Infinity;
for (const n of notesRef.current) {
if (n.judged || n.lane !== lane) { continue; }
const diff = Math.abs(noteNow(n) - g.current.fallMs);
if (diff < bestDiff) { bestDiff = diff; target = n; }
}
if (!target || bestDiff > WIN_MISS) { return; }
judge(target.id, bestDiff <= WIN_PERFECT ? "perfect" : (bestDiff <= WIN_GOOD ? "good" : "miss"));
};
useEffect(() => {
const onKey = (e: KeyEvt) => {
if (e.repeat) { return; }
const lane = KEY_LANE[e.key.toLowerCase()];
if (lane === undefined) { return; }
pressLane(lane);
};
const handler = onKey as unknown as (e: Event) => void;
window.addEventListener("keydown", handler);
return () => { window.removeEventListener("keydown", handler); };
}, []);
// 곡 시작 — 낙하 시간을 먼저 재고 스케줄을 animation-delay 로 심는다
const start = () => {
const st = g.current;
setPhase("play");
st.phase = "play";
setScore(0); setMiss(0); setCombo(0); setMaxCombo(0); setVerdict(null);
const list: Note[] = SONG.map((row, i) => ({ id: i + 1, lane: row[1], due: row[0] * BEAT, judged: null, due2: false }));
setNotes(list);
notesRef.current = list;
st.t0 = performance.now();
// duration 은 CSS 가 정한다 — 첫 노트가 붙은 뒤 한 프레임 안에 읽어 스케줄 계산에 쓴다
window.requestAnimationFrame(() => {
const el = document.getElementById("rtl-note-1");
const a = el ? fallAnim(el) : null;
const d = a && a.effect ? (a.effect.getTiming().duration as number) : 2000;
st.fallMs = typeof d === "number" && d > 0 ? d : 2000;
});
};
const ready = () => {
setPhase("ready");
g.current.phase = "ready";
setNotes([]); notesRef.current = [];
setScore(0); setMiss(0); setCombo(0); setMaxCombo(0); setVerdict(null);
};
api.current.start = start;
api.current.ready = ready;
// 놓친 노트를 치우고 곡이 끝났는지 본다
useEffect(() => {
if (phase !== "play") { return; }
const st = g.current;
let raf = 0;
const tick = () => {
let remain = 0;
for (const n of notesRef.current) {
if (n.judged) { continue; }
remain += 1;
const cur = noteNow(n);
if (st.reduce && !n.due2 && Math.abs(cur - st.fallMs) <= WIN_MISS) {
setNotes((prev) => prev.map((x) => (x.id === n.id ? { ...x, due2: true } : x)));
}
if (cur > st.fallMs + WIN_MISS) { judge(n.id, "miss"); remain -= 1; }
}
const lastDue = SONG[SONG.length - 1][0] * BEAT;
if (remain === 0 && performance.now() - st.t0 > lastDue + 900) {
setPhase("done");
st.phase = "done";
return;
}
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => { window.cancelAnimationFrame(raf); };
}, [phase]);
// 최고 점수는 곡이 끝난 순간에만 갱신한다
useEffect(() => {
if (phase !== "done" || score <= best) { return; }
setBest(score);
try { window.localStorage.setItem(BEST_KEY, String(score)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
}, [phase]);
// 미리보기 — 판정 글자와 콤보가 도는 연출만 담당한다 (채점은 미리보기를 뗀 뒤에만)
useEffect(() => {
if (phase !== "demo") { return; }
const kinds: Verdict[] = ["perfect", "good", "perfect", "miss"];
const combos = [4, 9, 16, 7];
let i = 0;
const id = window.setInterval(() => {
i += 1;
setDemoVerdict({ v: kinds[i % 4], combo: combos[i % 4] });
}, 700);
return () => { window.clearInterval(id); };
}, [phase]);
const vars: Vars = {};
if (duration) { vars["--duration"] = duration; }
if (easing) { vars["--easing"] = easing; }
if (accentColor) { vars["--color"] = accentColor; }
const isDemo = phase === "demo";
const shownVerdict: Verdict | null = isDemo ? demoVerdict.v : (verdict ? verdict.v : null);
const vCls = shownVerdict === "perfect" ? styles.vPerfect : (shownVerdict === "good" ? styles.vGood : styles.vMiss);
const comboShown = isDemo ? demoVerdict.combo : combo;
return (
<div className={[styles.root, isDemo ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>
<svg className={styles.ico} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M208 164a28 28 0 1 1-28-28a28 28 0 0 1 28 28m-156 4a28 28 0 1 0 28 28a28 28 0 0 0-28-28" opacity=".2" />
<path d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20" />
</svg>
<span>{TITLE_TEXT}</span>
</p>
<p className={styles.pill}><span className={styles.label}>{SCORE_LABEL}</span><b className={styles.num}>{score}</b></p>
<p className={styles.pill}><span className={styles.label}>{MISS_LABEL}</span><b className={styles.num}>{miss}</b></p>
<p className={styles.pill}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M200 48v63.1c0 39.7-31.75 72.6-71.45 72.9A72 72 0 0 1 56 112V48Z" opacity=".2" />
<path d="M232 64h-24V48a8 8 0 0 0-8-8H56a8 8 0 0 0-8 8v16H24A16 16 0 0 0 8 80v16a40 40 0 0 0 40 40h3.65A80.13 80.13 0 0 0 120 191.61V216H96a8 8 0 0 0 0 16h64a8 8 0 0 0 0-16h-24v-24.42c31.94-3.23 58.44-25.64 68.08-55.58H208a40 40 0 0 0 40-40V80a16 16 0 0 0-16-16M48 120a24 24 0 0 1-24-24V80h24v32q0 4 .39 8Zm144-8.9c0 35.52-29 64.64-64 64.9a64 64 0 0 1-64-64V56h128ZM232 96a24 24 0 0 1-24 24h-.5a82 82 0 0 0 .5-8.9V80h24Z" />
</svg>
<b className={styles.num}>{best}</b>
</p>
<button className={styles.btn} type="button" aria-label={AGAIN_LABEL} onClick={ready}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</svg>
</button>
</header>
<div className={styles.board} aria-label={BOARD_LABEL} onClick={() => { if (phase === "ready") { start(); } }}>
{[0, 1, 2].map((lane) => (
<div className={styles.lane} key={lane} style={{ "--k": lane } as CSSProperties}>
<div className={styles.well} ref={(el: HTMLDivElement | null) => { wellRefs.current[lane] = el; }}>
{isDemo && PREVIEW.filter((row) => row[0] === lane).map((row, i) => (
<span className={styles.pv} key={i} style={{ "--d": row[1] + "ms" } as CSSProperties} />
))}
{notes.filter((n) => n.lane === lane).map((n) => (
<span
key={n.id}
id={"rtl-note-" + n.id}
className={[styles.note,
n.judged === "miss" ? styles.noteMiss : "",
n.judged && n.judged !== "miss" ? styles.noteHit : "",
n.due2 ? styles.noteDue : ""].filter(Boolean).join(" ")}
style={{ animationDelay: (n.due - g.current.fallMs) + "ms" }}
/>
))}
</div>
<span className={styles.key}>{LANE_KEYS[lane]}</span>
</div>
))}
<span className={styles.line} aria-hidden="true" />
<p className={[styles.combo, isDemo ? styles.comboBump : ""].filter(Boolean).join(" ")} hidden={comboShown < 2}>
{COMBO_FMT.replace("{n}", String(comboShown))}
</p>
<p
className={[styles.verdict, vCls, shownVerdict ? styles.vShow : ""].filter(Boolean).join(" ")}
key={isDemo ? "demo-" + demoVerdict.combo : "hit-" + (verdict ? verdict.n : 0)}
aria-live="polite"
>
{shownVerdict ? verdictText(shownVerdict) : ""}
</p>
<div className={styles.cover} hidden={phase !== "ready"}>
<p className={styles.coverTitle}>{START_TITLE}</p>
<p className={styles.coverSub}>{START_SUB}</p>
</div>
<div className={styles.cover} hidden={phase !== "done"}>
<p className={styles.coverTitle}>{DONE_TITLE}</p>
<p className={styles.resultLine}>
{RESULT_FMT.replace("{s}", String(score)).replace("{c}", String(maxCombo)).replace("{m}", String(miss))}
</p>
<button className={styles.again} type="button" onClick={start}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</svg>
<span>{AGAIN_LABEL}</span>
</button>
</div>
</div>
<p className={styles.hint}>{HINT_TEXT}</p>
</div>
);
}05 리듬 탭 — 측정 누적면적 22.402% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 게임 만들기 — 미니게임 9종 복붙 코드
이 데모가 속한 글
CSS 게임 만들기 — 미니게임 9종 복붙 코드CSS 게임은 브라우저가 원래 갖고 있는 선택자·키프레임·transform 만으로 규칙과 화면을 함께 굴리는 놀이판입니다. 게임엔진 없이 HTML·CSS·바닐라 JS 로 아홉 개를 짰고, 아래 격자에서 지금 돌아갑니 이 글의 데모 전부 보기


