CSS 게임 만들기 — 미니게임 9종 복붙 코드
CSS 게임은 브라우저가 원래 갖고 있는 선택자·키프레임·transform 만으로 규칙과 화면을 함께 굴리는 놀이판입니다. 게임엔진 없이 HTML·CSS·바닐라 JS 로 아홉 개를 짰고, 아래 격자에서 지금 돌아갑니다.
01 lit-reaction-pad
02 memory-pair-flip
03 mole-whack-timer
04 gaze-dodge-eye
05 rhythm-tap-lane
06 focus-maze-escape
07 dodge-falling-run
08 stack-tower-timing
09 reaction-time-signal
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
순서는 인기순이 아니라 판정을 누가 쥐고 있느냐의 순서입니다. 앞의 셋은 판정이 거의 다 CSS 쪽에 있고(01 선택자 연쇄, 02 뒤집기 전환, 03 솟기 전환), 가운데 셋은 좌표와 포커스를 JS 가 읽어 CSS 가 그리며(04 커서 추적, 05 애니메이션 시계, 06 포커스 이동), 뒤의 셋은 프레임과 시간을 JS 가 직접 셉니다(07 낙하 회피, 08 왕복 정지, 09 반응 시간). 그래서 01 은 형제 결합자 하나로 순서를 만들기 때문에 css 선택자 게임을 찾다가 들어온 사람이 제일 먼저 열어 볼 항목이고, 09 로 갈수록 밀리초 단위 계산이 늘어납니다. 뒤집는 면 자체를 더 보고 싶다면 CSS 카드 뒤집기 애니메이션 9종, 커서를 따라오는 연출은 마우스를 따라오는 CSS 3D 회전 장면 9종, 키 입력 화면 부품은 키보드 단축키 UI 9종이 이웃입니다.
01불 켜지는 패드 누르기
지금 불이 켜진 패드 하나만 눌리고, 누를 때마다 다음 패드로 불이 옮겨 가는, 로딩 대신 30초를 때우거나 404 페이지에 볼거리로 얹을 놀이판입니다.

<main class="stage">
<div class="rp is-demo" id="rp">
<header class="rp__bar">
<p class="rp__title">불 켜지는 패드 누르기</p>
<button class="rp__restart" id="rpRestart" type="button" aria-label="다시 시작">
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><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></svg>
</button>
</header>
<p class="rp__hint">켜진 패드만 눌립니다. 스페이스로도 누를 수 있어요</p>
<div class="rp__board" id="rpBoard">
<input class="rp__check" type="checkbox" id="rpP1" aria-label="1번 패드">
<input class="rp__check" type="checkbox" id="rpP2" aria-label="2번 패드">
<input class="rp__check" type="checkbox" id="rpP3" aria-label="3번 패드">
<input class="rp__check" type="checkbox" id="rpP4" aria-label="4번 패드">
<input class="rp__check" type="checkbox" id="rpP5" aria-label="5번 패드">
<input class="rp__check" type="checkbox" id="rpP6" aria-label="6번 패드">
<input class="rp__check" type="checkbox" id="rpP7" aria-label="7번 패드">
<input class="rp__check" type="checkbox" id="rpP8" aria-label="8번 패드">
<input class="rp__check" type="checkbox" id="rpP9" aria-label="9번 패드">
<div class="rp__grid">
<label class="rp__pad" for="rpP1" style="--i: 0"><span class="rp__no">1</span></label>
<label class="rp__pad" for="rpP2" style="--i: 1"><span class="rp__no">2</span></label>
<label class="rp__pad" for="rpP3" style="--i: 2"><span class="rp__no">3</span></label>
<label class="rp__pad" for="rpP4" style="--i: 3"><span class="rp__no">4</span></label>
<label class="rp__pad" for="rpP5" style="--i: 4"><span class="rp__no">5</span></label>
<label class="rp__pad" for="rpP6" style="--i: 5"><span class="rp__no">6</span></label>
<label class="rp__pad" for="rpP7" style="--i: 6"><span class="rp__no">7</span></label>
<label class="rp__pad" for="rpP8" style="--i: 7"><span class="rp__no">8</span></label>
<label class="rp__pad" for="rpP9" style="--i: 8"><span class="rp__no">9</span></label>
</div>
<p class="rp__done" role="status"><b>9개 패드 클리어!</b></p>
</div>
<p class="rp__tally"><span class="rp__tallyLabel">누른 수</span> <b class="rp__count"></b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 불 켜지는 패드 누르기 — 순수 CSS 게임. input[type=checkbox] 를 형제 결합자(~)로 연결해
// "바로 앞 패드가 :checked 일 때만 다음 패드에 불이 켜진다"는 연쇄를 만든다.
// 점수는 체크된 input 이 counter-increment 하고 아랫줄 .rp__count::after 가 content: counter() 로 읽는다.
// 9번째까지 체크됐다는 건 연쇄 위에서 1번부터 8번까지 전부 눌렸다는 뜻이므로, 완료 판정도 #rpP9:checked 하나로 충분하다.
// 격자 미리보기(.is-demo)는 9개 패드가 순서대로 눌리는 연출을 프리뷰 레이어(::before 리플 + ::after 채움)로 보여 준다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-ink);
.rp {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 340px; height: 100%;
counter-reset: rpTaps;
}
.rp__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.rp__title { margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 15px); color: $subject-cream; }
.rp__restart {
display: grid; place-items: center; width: clamp(24px, 5vw, 28px); aspect-ratio: 1; padding: 0;
border: 2px solid rgba($subject-cream, .3); border-radius: $r-control;
background: none; color: $subject-cream; cursor: pointer;
}
.rp__restart svg { width: 62%; height: 62%; }
.rp__restart:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.rp__hint { margin: 0; font-weight: 500; font-size: clamp(8px, 2.2vw, 10px); color: rgba($subject-cream, .72); }
.rp__board { position: relative; flex: 1 1 auto; min-height: 0; display: flex; }
// 체크박스는 화면에 그리지 않지만 카운터와 키보드 초점을 위해서 상자를 유지한다(display:none 은 카운터를 끊는다)
.rp__check { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; pointer-events: none; }
.rp__check:checked { counter-increment: rpTaps; }
.rp__grid { flex: 1 1 auto; display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr); gap: $sp-2; }
.rp__pad {
position: relative; display: grid; place-items: center;
border-radius: $r-control; border: 2px solid rgba($subject-cream, .14);
background-color: rgba($subject-cream, .05);
pointer-events: none; cursor: default;
transition: background-color $dur-quick $ease-out, border-color $dur-quick $ease-out,
box-shadow $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.rp__no { position: relative; z-index: 1; font-weight: 800; font-size: clamp(11px, 3vw, 16px); color: rgba($subject-cream, .72); }
// 프리뷰 채움(::after)과 리플 고리(::before) — .is-demo 연출에서만 움직인다
.rp__pad::after { content: ""; position: absolute; inset: 0; z-index: 0; border-radius: inherit; background-color: $color; opacity: 0; }
.rp__pad::before { content: ""; position: absolute; inset: -2px; z-index: 2; border: 2px solid $subject-cream; border-radius: inherit; opacity: 0; }
// ── 연쇄 규칙 ───────────────────────────────────────────────
// 꺼진(아직 못 누른) 패드는 pointer-events:none 이라 클릭이 닿지 않고, 켜진 패드만 auto 로 살아난다
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1),
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) {
pointer-events: auto; cursor: pointer;
background-color: $subject-cream; border-color: $subject-cream;
box-shadow: 0 0 0 4px rgba($subject-cream, .16), 0 0 22px rgba($subject-cream, .3);
.rp__no { color: $subject-ink; }
}
// 누른 패드는 색이 바뀌어 남는다 — 연쇄가 순서를 보장하므로 반칙 클릭은 애초에 불가능하다
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(1),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP9:checked ~ .rp__grid .rp__pad:nth-child(9) {
background-color: $color; border-color: $color; box-shadow: none;
.rp__no { color: $subject-cream; }
}
// 키보드 초점도 켜진 패드에만 보인다 — 초점 자체는 JS 가 켜진 패드로 옮긴다
#rpP1:focus-visible ~ .rp__grid .rp__pad:nth-child(1),
#rpP2:focus-visible ~ .rp__grid .rp__pad:nth-child(2),
#rpP3:focus-visible ~ .rp__grid .rp__pad:nth-child(3),
#rpP4:focus-visible ~ .rp__grid .rp__pad:nth-child(4),
#rpP5:focus-visible ~ .rp__grid .rp__pad:nth-child(5),
#rpP6:focus-visible ~ .rp__grid .rp__pad:nth-child(6),
#rpP7:focus-visible ~ .rp__grid .rp__pad:nth-child(7),
#rpP8:focus-visible ~ .rp__grid .rp__pad:nth-child(8),
#rpP9:focus-visible ~ .rp__grid .rp__pad:nth-child(9) {
outline: 2px solid $subject-cream; outline-offset: 2px;
}
.rp__pad:active { transform: scale(.93); }
// 완료 — #rpP9 가 체크됐다면 1부터 8까지 전부 지나온 상태다
.rp__done { position: absolute; inset: 0; z-index: 3; display: none; place-items: center; margin: 0; }
#rpP9:checked ~ .rp__done { display: grid; }
.rp__done b {
display: inline-block; padding: $sp-2 $sp-4; border-radius: $r-pill;
background-color: $subject-cream; color: $subject-ink;
font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); box-shadow: $shadow-press;
animation-name: rpDoneIn; animation-duration: $dur-enter; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: both;
}
@keyframes rpDoneIn {
from { opacity: 0; transform: scale(.8); }
to { opacity: 1; transform: scale(1); }
}
.rp__tally { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; margin: 0; }
.rp__tallyLabel { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .72); }
.rp__count { font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); color: $subject-cream; }
.rp__count::after { content: counter(rpTaps) " / 9"; }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 9개 패드가 2초 안에 순서대로 눌린다 — 눌림(scale)·채움(opacity)·리플 고리(scale+fade)를 같은 지연으로 묶는다
.rp.is-demo .rp__pad,
.rp.is-demo .rp__pad::after,
.rp.is-demo .rp__pad::before,
.rp.is-demo .rp__no {
animation-duration: 380ms; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 180ms);
}
.rp.is-demo .rp__pad { animation-name: rpPress; }
.rp.is-demo .rp__pad::after { animation-name: rpFill; }
.rp.is-demo .rp__pad::before { animation-name: rpRipple; }
.rp.is-demo .rp__no { animation-name: rpNo; }
@keyframes rpPress {
0% { transform: scale(1); }
28% { transform: scale(.9); }
58% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes rpFill {
0% { opacity: 0; }
26% { opacity: 1; }
78% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes rpRipple {
0% { transform: scale(1); opacity: 0; }
26% { opacity: .9; transform: scale(1); }
100% { transform: scale(1.32); opacity: 0; }
}
@keyframes rpNo {
0% { color: rgba($subject-cream, .72); }
26% { color: $subject-cream; }
78% { color: $subject-cream; }
100% { color: rgba($subject-cream, .72); }
}
@media (max-width: 340px) {
.rp { gap: $sp-1; }
.rp__grid { gap: $sp-1; }
}
// reduce 에서는 연출을 끄고 현재 상태 한 장만 보여 준다 — 눌림 반응(transition)은 남긴다
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__pad,
.rp.is-demo .rp__pad::before,
.rp.is-demo .rp__pad::after,
.rp.is-demo .rp__no { animation: none !important; }
.rp__done b { animation: none !important; }
.rp__pad { transition: 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);
}
.rp {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 340px;
height: 100%;
counter-reset: rpTaps;
}
.rp__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rp__title {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 15px);
color: #fff7e6;
}
.rp__restart {
display: grid;
place-items: center;
width: clamp(24px, 5vw, 28px);
aspect-ratio: 1;
padding: 0;
border: 2px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
background: none;
color: #fff7e6;
cursor: pointer;
}
.rp__restart svg {
width: 62%;
height: 62%;
}
.rp__restart:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rp__hint {
margin: 0;
font-weight: 500;
font-size: clamp(8px, 2.2vw, 10px);
color: rgba(255, 247, 230, 0.72);
}
.rp__board {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
}
.rp__check {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
opacity: 0;
pointer-events: none;
}
.rp__check:checked {
counter-increment: rpTaps;
}
.rp__grid {
flex: 1 1 auto;
display: grid;
grid-template: repeat(3, 1fr)/repeat(3, 1fr);
gap: 8px;
}
.rp__pad {
position: relative;
display: grid;
place-items: center;
border-radius: 12px;
border: 2px solid rgba(255, 247, 230, 0.14);
background-color: rgba(255, 247, 230, 0.05);
pointer-events: none;
cursor: default;
transition: background-color 220ms ease-out, border-color 220ms ease-out, box-shadow 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rp__no {
position: relative;
z-index: 1;
font-weight: 800;
font-size: clamp(11px, 3vw, 16px);
color: rgba(255, 247, 230, 0.72);
}
.rp__pad::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background-color: #0a4fe8;
opacity: 0;
}
.rp__pad::before {
content: "";
position: absolute;
inset: -2px;
z-index: 2;
border: 2px solid #fff7e6;
border-radius: inherit;
opacity: 0;
}
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1),
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) {
pointer-events: auto;
cursor: pointer;
background-color: #fff7e6;
border-color: #fff7e6;
box-shadow: 0 0 0 4px rgba(255, 247, 230, 0.16), 0 0 22px rgba(255, 247, 230, 0.3);
}
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1) .rp__no,
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2) .rp__no,
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3) .rp__no,
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4) .rp__no,
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5) .rp__no,
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6) .rp__no,
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7) .rp__no,
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8) .rp__no,
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) .rp__no {
color: #17141a;
}
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(1),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP9:checked ~ .rp__grid .rp__pad:nth-child(9) {
background-color: #0a4fe8;
border-color: #0a4fe8;
box-shadow: none;
}
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(1) .rp__no,
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(2) .rp__no,
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(3) .rp__no,
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(4) .rp__no,
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(5) .rp__no,
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(6) .rp__no,
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(7) .rp__no,
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(8) .rp__no,
#rpP9:checked ~ .rp__grid .rp__pad:nth-child(9) .rp__no {
color: #fff7e6;
}
#rpP1:focus-visible ~ .rp__grid .rp__pad:nth-child(1),
#rpP2:focus-visible ~ .rp__grid .rp__pad:nth-child(2),
#rpP3:focus-visible ~ .rp__grid .rp__pad:nth-child(3),
#rpP4:focus-visible ~ .rp__grid .rp__pad:nth-child(4),
#rpP5:focus-visible ~ .rp__grid .rp__pad:nth-child(5),
#rpP6:focus-visible ~ .rp__grid .rp__pad:nth-child(6),
#rpP7:focus-visible ~ .rp__grid .rp__pad:nth-child(7),
#rpP8:focus-visible ~ .rp__grid .rp__pad:nth-child(8),
#rpP9:focus-visible ~ .rp__grid .rp__pad:nth-child(9) {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rp__pad:active {
transform: scale(0.93);
}
.rp__done {
position: absolute;
inset: 0;
z-index: 3;
display: none;
place-items: center;
margin: 0;
}
#rpP9:checked ~ .rp__done {
display: grid;
}
.rp__done b {
display: inline-block;
padding: 8px 16px;
border-radius: 999px;
background-color: #fff7e6;
color: #17141a;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
animation-name: rpDoneIn;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: both;
}
@keyframes rpDoneIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.rp__tally {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0;
}
.rp__tallyLabel {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.72);
}
.rp__count {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
color: #fff7e6;
}
.rp__count::after {
content: counter(rpTaps) " / 9";
}
.rp.is-demo .rp__pad,
.rp.is-demo .rp__pad::after,
.rp.is-demo .rp__pad::before,
.rp.is-demo .rp__no {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 180ms);
}
.rp.is-demo .rp__pad {
animation-name: rpPress;
}
.rp.is-demo .rp__pad::after {
animation-name: rpFill;
}
.rp.is-demo .rp__pad::before {
animation-name: rpRipple;
}
.rp.is-demo .rp__no {
animation-name: rpNo;
}
@keyframes rpPress {
0% {
transform: scale(1);
}
28% {
transform: scale(0.9);
}
58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes rpFill {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes rpRipple {
0% {
transform: scale(1);
opacity: 0;
}
26% {
opacity: 0.9;
transform: scale(1);
}
100% {
transform: scale(1.32);
opacity: 0;
}
}
@keyframes rpNo {
0% {
color: rgba(255, 247, 230, 0.72);
}
26% {
color: #fff7e6;
}
78% {
color: #fff7e6;
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
@media (max-width: 340px) {
.rp {
gap: 4px;
}
.rp__grid {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__pad,
.rp.is-demo .rp__pad::before,
.rp.is-demo .rp__pad::after,
.rp.is-demo .rp__no {
animation: none !important;
}
.rp__done b {
animation: none !important;
}
.rp__pad {
transition: none !important;
}
}// 게임 판정 자체는 전부 CSS(:checked 연쇄 + counter)다. JS 는 두 가지 보조만 한다 —
// 재시작(체크 해제)과 키보드 초점이 켜진 패드를 따라가는 일.
(function () {
var root = document.getElementById('rp');
var checks = [].slice.call(document.querySelectorAll('.rp__check'));
var count = checks.length;
function litIndex() {
for (var i = 0; i < count; i++) { if (!checks[i].checked) { return i; } }
return -1;
}
function sync(focusLit) {
var k = litIndex();
for (var i = 0; i < count; i++) { checks[i].tabIndex = (k === i || k < 0) ? 0 : -1; }
if (focusLit && k >= 0) { checks[k].focus({ preventScroll: true }); }
}
root.addEventListener('change', function () {
root.classList.remove('is-demo');
sync(true);
});
document.getElementById('rpRestart').addEventListener('click', function () {
for (var i = 0; i < count; i++) { checks[i].checked = false; }
root.classList.remove('is-demo');
sync(true);
});
sync(false);
})();// LitReactionPad — vanilla 판(run/301/demo/01-lit-reaction-pad/index.html) 의 React 포트.
// 게임 판정은 전부 CSS(:checked 연쇄 + counter)다. React 는 상태(체크 9개)를 들고 있고
// 재시작과 키보드 초점(켜진 패드로 이동)만 담당한다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LitReactionPad.module.scss";
export type LitReactionPadProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PadVars = CSSProperties & { "--i"?: string };
const TOTAL = 9;
const TITLE = "불 켜지는 패드 누르기";
const HINT = "켜진 패드만 눌립니다. 스페이스로도 누를 수 있어요";
const RESTART_LABEL = "다시 시작";
const PAD_LABEL = "{n}번 패드";
const DONE_TEXT = "9개 패드 클리어!";
const TALLY_LABEL = "누른 수";
function RestartIcon() {
return (
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<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>
</svg>
);
}
export default function LitReactionPad({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#0a4fe8",
}: LitReactionPadProps) {
const [checks, setChecks] = useState<boolean[]>(Array(TOTAL).fill(false));
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
const litIndex = checks.findIndex((v) => !v);
function toggle(i: number) {
setDemo(false);
setChecks((prev) => prev.map((v, k) => (k === i ? !v : v)));
}
function restart() {
setDemo(false);
setChecks(Array(TOTAL).fill(false));
}
function padVars(i: number): PadVars {
return { "--i": String(i) };
}
function padLabel(i: number): string {
return PAD_LABEL.replace("{n}", String(i + 1));
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>{TITLE}</p>
<button className={styles.restart} type="button" aria-label={RESTART_LABEL} onClick={restart}>
<RestartIcon />
</button>
</header>
<p className={styles.hint}>{HINT}</p>
<div className={styles.board}>
{checks.map((v, i) => (
<input
key={i}
className={styles.check}
type="checkbox"
id={`rpP${i + 1}`}
aria-label={padLabel(i)}
checked={v}
tabIndex={litIndex === i || litIndex < 0 ? 0 : -1}
onChange={() => toggle(i)}
/>
))}
<div className={styles.grid}>
{checks.map((v, i) => (
<label key={i} className={styles.pad} htmlFor={`rpP${i + 1}`} style={padVars(i)}>
<span className={styles.no}>{i + 1}</span>
</label>
))}
</div>
<p className={styles.done} role="status">
<b>{DONE_TEXT}</b>
</p>
</div>
<p className={styles.tally}>
<span className={styles.tallyLabel}>{TALLY_LABEL}</span> <b className={styles.count}></b>
</p>
</div>
</main>
);
}01 · 불 켜지는 패드 누르기 — 측정 누적면적 47.79% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
형제 결합자로 만든 순서 잠금
// 꺼진(아직 못 누른) 패드는 pointer-events:none 이라 클릭이 닿지 않고, 켜진 패드만 auto 로 살아난다
#rpP1:not(:checked) ~ .rp__grid .rp__pad:nth-child(1),
#rpP1:checked ~ .rp__grid .rp__pad:nth-child(2),
#rpP2:checked ~ .rp__grid .rp__pad:nth-child(3),
#rpP3:checked ~ .rp__grid .rp__pad:nth-child(4),
#rpP4:checked ~ .rp__grid .rp__pad:nth-child(5),
#rpP5:checked ~ .rp__grid .rp__pad:nth-child(6),
#rpP6:checked ~ .rp__grid .rp__pad:nth-child(7),
#rpP7:checked ~ .rp__grid .rp__pad:nth-child(8),
#rpP8:checked ~ .rp__grid .rp__pad:nth-child(9) {
pointer-events: auto; cursor: pointer;
background-color: $subject-cream; border-color: $subject-cream;
box-shadow: 0 0 0 4px rgba($subject-cream, .16), 0 0 22px rgba($subject-cream, .3);
.rp__no { color: $subject-ink; }
}
앞 번호가 :checked 가 되어야 다음 번호에 불이 들어오므로 순서를 어기는 클릭은 규칙이 아니라 구조로 막히고, MDN :checked 문서가 말하는 그 의사 클래스 하나가 판정 전체를 맡습니다.
점수는 CSS 카운터가 센다
.rp {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 340px; height: 100%;
counter-reset: rpTaps;
}
// 체크박스는 화면에 그리지 않지만 카운터와 키보드 초점을 위해서 상자를 유지한다(display:none 은 카운터를 끊는다)
.rp__check { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; pointer-events: none; }
.rp__check:checked { counter-increment: rpTaps; }
.rp__count::after { content: counter(rpTaps) " / 9"; }
// 완료 — #rpP9 가 체크됐다면 1부터 8까지 전부 지나온 상태다
.rp__done { position: absolute; inset: 0; z-index: 3; display: none; place-items: center; margin: 0; }
#rpP9:checked ~ .rp__done { display: grid; }
점수를 세는 JS 는 한 줄도 없고 애니메이션 36개가 얹힌 이 판의 누적면적은 47.79%, 움직인 프레임은 23/23 으로 아홉 중 가장 촘촘하게 움직입니다.
02짝 맞추기 카드
뒤집은 두 장이 같으면 그대로 남고 다르면 도로 덮이는, 심심풀이로도 쓰고 카드 앞면에 문제를 넣으면 학습 카드로도 쓰는 판입니다.

<main class="stage">
<div class="mm is-demo" id="mm">
<header class="mm__bar">
<p class="mm__title">짝 맞추기 카드</p>
<button class="mm__restart" id="mmRestart" type="button" aria-label="다시 섞기">
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><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></svg>
</button>
</header>
<div class="mm__stats">
<span class="mm__stat"><b class="mm__num" id="mmTries">0</b><span class="mm__label">시도</span></span>
<span class="mm__stat"><b class="mm__num" id="mmPairs">6</b><span class="mm__label">남은 짝</span></span>
<span class="mm__stat mm__stat--best" aria-label="최고 기록">
<svg class="mm__trophy" viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><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></svg>
<b class="mm__num" id="mmBest">-</b>
</span>
</div>
<div class="mm__grid" id="mmGrid">
<button class="mm__card" type="button" data-face="star" aria-label="1번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="heart" aria-label="2번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="moon" aria-label="3번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78" opacity=".2"/><path d="M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="rocket" aria-label="4번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m94.81 192l-29.45 22.24a8 8 0 0 1-12.81-4.51L40.19 154.1a8 8 0 0 1 1.66-6.86l30.31-36.33C71 134.25 76.7 161.43 94.81 192m119.34-44.76l-30.31-36.33c1.21 23.34-4.54 50.52-22.65 81.09l29.45 22.24a8 8 0 0 0 12.81-4.51l12.36-55.63a8 8 0 0 0-1.66-6.86" opacity=".2"/><path d="M152 224a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8m-24-112a12 12 0 1 0-12-12a12 12 0 0 0 12 12m95.62 43.83l-12.36 55.63a16 16 0 0 1-25.51 9.11L158.51 200h-61l-27.26 20.57a16 16 0 0 1-25.51-9.11l-12.36-55.63a16.09 16.09 0 0 1 3.32-13.71l28.56-34.26a123 123 0 0 1 8.57-36.67c12.9-32.34 36-52.63 45.37-59.85a16 16 0 0 1 19.6 0c9.34 7.22 32.47 27.51 45.37 59.85a123 123 0 0 1 8.57 36.67l28.56 34.26a16.09 16.09 0 0 1 3.32 13.71M99.43 184h57.14c21.12-37.54 25.07-73.48 11.74-106.88C156.55 47.64 134.49 29 128 24c-6.51 5-28.57 23.64-40.33 53.12c-13.31 33.4-9.36 69.34 11.76 106.88m-15 5.85q-16.15-29.35-19.6-57.69L48 152.36L60.36 208l.18-.13ZM208 152.36l-16.83-20.2q-3.42 28.28-19.56 57.69l23.85 18l.18.13Z"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="lightning" aria-label="5번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="flower" aria-label="6번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M206.35 136.29c-8.87-5.13-24.46-7.38-39.4-8.29c14.94-.91 30.53-3.16 39.4-8.29a32 32 0 1 0-32-55.42c-8.87 5.12-18.61 17.48-26.87 30C154.17 80.87 160 66.25 160 56a32 32 0 0 0-64 0c0 10.25 5.83 24.87 12.52 38.26c-8.26-12.49-18-24.85-26.87-30a32 32 0 1 0-32 55.42c8.87 5.13 24.46 7.38 39.4 8.29c-14.94.91-30.53 3.16-39.4 8.29a32 32 0 1 0 32 55.42c8.87-5.12 18.61-17.48 26.87-30C101.83 175.13 96 189.75 96 200a32 32 0 0 0 64 0c0-10.25-5.83-24.87-12.52-38.26c8.26 12.49 18 24.85 26.87 30a32 32 0 1 0 32-55.42ZM155.71 144a32 32 0 1 1 4.29-16a31.74 31.74 0 0 1-4.29 16" opacity=".2"/><path d="M210.35 129.36c-.81-.47-1.7-.92-2.62-1.36c.92-.44 1.81-.89 2.62-1.36a40 40 0 1 0-40-69.28c-.81.47-1.65 1-2.48 1.59c.08-1 .13-2 .13-3a40 40 0 0 0-80 0c0 .94 0 1.94.13 3c-.83-.57-1.67-1.12-2.48-1.59a40 40 0 1 0-40 69.28c.81.47 1.7.92 2.62 1.36c-.92.44-1.81.89-2.62 1.36a40 40 0 1 0 40 69.28c.81-.47 1.65-1 2.48-1.59c-.08 1-.13 1.94-.13 2.95a40 40 0 0 0 80 0c0-.94-.05-1.94-.13-2.95c.83.57 1.67 1.12 2.48 1.59a39.8 39.8 0 0 0 19.94 5.36a40.4 40.4 0 0 0 10.42-1.38a40 40 0 0 0 9.64-73.28ZM104 128a24 24 0 1 1 24 24a24 24 0 0 1-24-24m74.35-56.79a24 24 0 1 1 24 41.57c-6.27 3.63-18.61 6.13-35.16 7.19a40 40 0 0 0-12.66-21.87c9.2-13.82 17.55-23.26 23.82-26.89M128 32a24 24 0 0 1 24 24c0 7.24-4 19.19-11.36 34.06a39.8 39.8 0 0 0-25.28 0C108.36 75.19 104 63.24 104 56a24 24 0 0 1 24-24M44.86 80a24 24 0 0 1 32.79-8.79c6.27 3.63 14.62 13.07 23.82 26.89A40 40 0 0 0 88.81 120c-16.55-1.06-28.89-3.56-35.16-7.18A24 24 0 0 1 44.86 80m32.79 104.79a24 24 0 1 1-24-41.57c6.27-3.63 14.62-6.13 35.16-7.19a40 40 0 0 0 12.66 21.87c-9.2 13.82-17.55 23.26-23.82 26.89M128 224a24 24 0 0 1-24-24c0-7.24 4-19.19 11.36-34.06a39.8 39.8 0 0 0 25.28 0C148.36 180.81 152 192.76 152 200a24 24 0 0 1-24 24m83.14-48a24 24 0 0 1-32.79 8.79c-6.27-3.63-14.62-13.07-23.82-26.89a40 40 0 0 0 12.66-21.9c16.55 1.06 28.89 3.56 35.16 7.18a24 24 0 0 1 8.79 32.82"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="star" aria-label="7번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="heart" aria-label="8번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="moon" aria-label="9번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78" opacity=".2"/><path d="M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="rocket" aria-label="10번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m94.81 192l-29.45 22.24a8 8 0 0 1-12.81-4.51L40.19 154.1a8 8 0 0 1 1.66-6.86l30.31-36.33C71 134.25 76.7 161.43 94.81 192m119.34-44.76l-30.31-36.33c1.21 23.34-4.54 50.52-22.65 81.09l29.45 22.24a8 8 0 0 0 12.81-4.51l12.36-55.63a8 8 0 0 0-1.66-6.86" opacity=".2"/><path d="M152 224a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8m-24-112a12 12 0 1 0-12-12a12 12 0 0 0 12 12m95.62 43.83l-12.36 55.63a16 16 0 0 1-25.51 9.11L158.51 200h-61l-27.26 20.57a16 16 0 0 1-25.51-9.11l-12.36-55.63a16.09 16.09 0 0 1 3.32-13.71l28.56-34.26a123 123 0 0 1 8.57-36.67c12.9-32.34 36-52.63 45.37-59.85a16 16 0 0 1 19.6 0c9.34 7.22 32.47 27.51 45.37 59.85a123 123 0 0 1 8.57 36.67l28.56 34.26a16.09 16.09 0 0 1 3.32 13.71M99.43 184h57.14c21.12-37.54 25.07-73.48 11.74-106.88C156.55 47.64 134.49 29 128 24c-6.51 5-28.57 23.64-40.33 53.12c-13.31 33.4-9.36 69.34 11.76 106.88m-15 5.85q-16.15-29.35-19.6-57.69L48 152.36L60.36 208l.18-.13ZM208 152.36l-16.83-20.2q-3.42 28.28-19.56 57.69l23.85 18l.18.13Z"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="lightning" aria-label="11번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></svg></span>
</span>
</button>
<button class="mm__card" type="button" data-face="flower" aria-label="12번 카드" aria-pressed="false">
<span class="mm__inner">
<span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span>
<span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M206.35 136.29c-8.87-5.13-24.46-7.38-39.4-8.29c14.94-.91 30.53-3.16 39.4-8.29a32 32 0 1 0-32-55.42c-8.87 5.12-18.61 17.48-26.87 30C154.17 80.87 160 66.25 160 56a32 32 0 0 0-64 0c0 10.25 5.83 24.87 12.52 38.26c-8.26-12.49-18-24.85-26.87-30a32 32 0 1 0-32 55.42c8.87 5.13 24.46 7.38 39.4 8.29c-14.94.91-30.53 3.16-39.4 8.29a32 32 0 1 0 32 55.42c8.87-5.12 18.61-17.48 26.87-30C101.83 175.13 96 189.75 96 200a32 32 0 0 0 64 0c0-10.25-5.83-24.87-12.52-38.26c8.26 12.49 18 24.85 26.87 30a32 32 0 1 0 32-55.42ZM155.71 144a32 32 0 1 1 4.29-16a31.74 31.74 0 0 1-4.29 16" opacity=".2"/><path d="M210.35 129.36c-.81-.47-1.7-.92-2.62-1.36c.92-.44 1.81-.89 2.62-1.36a40 40 0 1 0-40-69.28c-.81.47-1.65 1-2.48 1.59c.08-1 .13-2 .13-3a40 40 0 0 0-80 0c0 .94 0 1.94.13 3c-.83-.57-1.67-1.12-2.48-1.59a40 40 0 1 0-40 69.28c.81.47 1.7.92 2.62 1.36c-.92.44-1.81.89-2.62 1.36a40 40 0 1 0 40 69.28c.81-.47 1.65-1 2.48-1.59c-.08 1-.13 1.94-.13 2.95a40 40 0 0 0 80 0c0-.94-.05-1.94-.13-2.95c.83.57 1.67 1.12 2.48 1.59a39.8 39.8 0 0 0 19.94 5.36a40.4 40.4 0 0 0 10.42-1.38a40 40 0 0 0 9.64-73.28ZM104 128a24 24 0 1 1 24 24a24 24 0 0 1-24-24m74.35-56.79a24 24 0 1 1 24 41.57c-6.27 3.63-18.61 6.13-35.16 7.19a40 40 0 0 0-12.66-21.87c9.2-13.82 17.55-23.26 23.82-26.89M128 32a24 24 0 0 1 24 24c0 7.24-4 19.19-11.36 34.06a39.8 39.8 0 0 0-25.28 0C108.36 75.19 104 63.24 104 56a24 24 0 0 1 24-24M44.86 80a24 24 0 0 1 32.79-8.79c6.27 3.63 14.62 13.07 23.82 26.89A40 40 0 0 0 88.81 120c-16.55-1.06-28.89-3.56-35.16-7.18A24 24 0 0 1 44.86 80m32.79 104.79a24 24 0 1 1-24-41.57c6.27-3.63 14.62-6.13 35.16-7.19a40 40 0 0 0 12.66 21.87c-9.2 13.82-17.55 23.26-23.82 26.89M128 224a24 24 0 0 1-24-24c0-7.24 4-19.19 11.36-34.06a39.8 39.8 0 0 0 25.28 0C148.36 180.81 152 192.76 152 200a24 24 0 0 1-24 24m83.14-48a24 24 0 0 1-32.79 8.79c-6.27-3.63-14.62-13.07-23.82-26.89a40 40 0 0 0 12.66-21.9c16.55 1.06 28.89 3.56 35.16 7.18a24 24 0 0 1 8.79 32.82"/></g></svg></span>
</span>
</button>
<span class="mm__ghost mm__ghost--a" aria-hidden="true"><span class="mm__ghostInner"><span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span><span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span></span></span>
<span class="mm__ghost mm__ghost--b" aria-hidden="true"><span class="mm__ghostInner"><span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span><span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg></span></span></span>
<span class="mm__ghost mm__ghost--c" aria-hidden="true"><span class="mm__ghostInner"><span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span><span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg></span></span></span>
<span class="mm__ghost mm__ghost--d" aria-hidden="true"><span class="mm__ghostInner"><span class="mm__face mm__face--back"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86" opacity=".2"/><path d="M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8"/></g></svg></span><span class="mm__face mm__face--front"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg></span></span></span>
<p class="mm__clear" id="mmClear" role="status" hidden><b id="mmClearMsg">클리어! 시도 {n}회 · {t}초</b></p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 짝 맞추기 카드 — 4×3 12장(6쌍). 카드는 <button> 이라 탭+엔터로 뒤집힌다.
// 뒤집기: .mm__inner 에 transform-style:preserve-3d 와 rotateY(180deg) 전환, 앞뒷면은 backface-visibility:hidden.
// 정적 상태(사람이 안 만진 첫 화면)는 뒷면 12장이 깔린 완결된판 — 격자 미리보기(.is-demo)는
// 실제 카드 위에 겹쳐 둔 고스트 카드 4장이 순서대로 뒤집히며 짝이 맞아 하이라이트되는 연출만 보여 준다.
// 3D 플립의 90도 모서리뷰가 poster 를 잡는 문제(252-07) 때문에 미리보기 회전은 steps(1,end) 컷 — 실제 클릭은 부드러운 transition 그대로.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.mm { display: flex; flex-direction: column; gap: $sp-2; width: 100%; max-width: 280px; height: 100%; }
.mm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.mm__title { margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 15px); color: $subject-ink; }
.mm__restart {
display: grid; place-items: center; width: clamp(24px, 5vw, 28px); aspect-ratio: 1; padding: 0;
border: 2px solid rgba($subject-ink, .32); border-radius: $r-control;
background: none; color: $subject-ink; cursor: pointer;
}
.mm__restart svg { width: 62%; height: 62%; }
.mm__restart:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.mm__stats { display: flex; align-items: baseline; gap: $sp-3; }
.mm__stat { display: inline-flex; align-items: baseline; gap: $sp-1; }
.mm__num { font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); color: $subject-ink; }
.mm__label { font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); color: rgba($subject-ink, .72); }
.mm__trophy { width: clamp(12px, 3vw, 14px); height: clamp(12px, 3vw, 14px); color: $subject-orange-deep; }
.mm__grid { position: relative; flex: 1 1 auto; min-height: 0; display: grid; grid-template: repeat(3, 1fr) / repeat(4, 1fr); gap: $sp-2; }
.mm__card { position: relative; width: 100%; height: 100%; padding: 0; border: 0; background: none; cursor: pointer; }
.mm__card:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.mm__inner {
position: absolute; inset: 0;
transform-style: preserve-3d;
transition: transform $dur-base $easing;
}
.mm__card.is-open .mm__inner,
.mm__card.is-matched .mm__inner { transform: rotateY(180deg); }
.mm__face {
position: absolute; inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: grid; place-items: center;
border-radius: $r-sm;
}
.mm__face svg { width: clamp(18px, 6vw, 26px); height: clamp(18px, 6vw, 26px); }
.mm__face--back {
background-color: $subject-cream;
border: 2px solid rgba($subject-ink, .18);
color: $color; // 뒷면 반짝임 문양은 진파랑
}
.mm__face--front {
background-color: $stage-paper;
border: 2px solid rgba($subject-ink, .14);
transform: rotateY(180deg);
}
// 앞면 아이콘 색 — 노랑 무대 위 흰 카드 면에 AA 를 넘기는 넉 색만
.mm__card[data-face="star"] .mm__face--front,
.mm__card[data-face="lightning"] .mm__face--front { color: $subject-orange-deep; }
.mm__card[data-face="heart"] .mm__face--front { color: $error; }
.mm__card[data-face="moon"] .mm__face--front,
.mm__card[data-face="flower"] .mm__face--front { color: $subject-blue-deep; }
.mm__card[data-face="rocket"] .mm__face--front { color: $subject-ink; }
// 매치된 짝은 테두리와 광택으로 남는다 — 팝 애니메이션은 자기 중심에서
.mm__card.is-matched { animation-name: mmPop; animation-duration: 420ms; animation-timing-function: $easing; animation-iteration-count: 1; }
.mm__card.is-matched .mm__face--front { border-color: $color; box-shadow: 0 0 0 3px rgba($color, .22); }
@keyframes mmPop {
0% { transform: scale(1); }
45% { transform: scale(1.09); }
100% { transform: scale(1); }
}
// 클리어 오버레이 — [hidden] 과 display 를 함께 쓰면 [hidden] 이 지는 것을 막는다
.mm__clear { position: absolute; inset: 0; z-index: 6; display: grid; place-items: center; margin: 0; background-color: rgba($subject-ink, .55); border-radius: $r-sm; }
.mm__clear[hidden] { display: none; }
.mm__clear b {
display: inline-block; padding: $sp-2 $sp-4; border-radius: $r-pill;
background-color: $color; color: $subject-cream;
font-weight: 800; font-size: clamp(10px, 2.6vw, 13px);
animation-name: mmClearIn; animation-duration: $dur-enter; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: both;
}
@keyframes mmClearIn {
from { opacity: 0; transform: scale(.82); }
to { opacity: 1; transform: scale(1); }
}
// ── 격자 미리보기 고스트 카드 ─────────────────────────────────
// 실제 패 위에 같은 크기로 겹쳐 둔 연출용 카드. 짝(하트 2장 · 별 2장)이 순서대로 뒤집혀 하이라이트된다.
// 사람이 손을 대면 .is-demo 가 떨어져 사라진다.
.mm__ghost { display: none; position: absolute; z-index: 4; pointer-events: none; }
.mm.is-demo .mm__ghost { display: block; }
.mm__ghost--a { grid-area: 1 / 2; }
.mm__ghost--b { grid-area: 3 / 3; }
.mm__ghost--c { grid-area: 1 / 4; }
.mm__ghost--d { grid-area: 2 / 1; }
.mm__ghostInner {
position: absolute; inset: 0;
transform-style: preserve-3d;
animation-name: mmGhostFlip;
animation-duration: $duration;
animation-timing-function: steps(1, end); // 모서리뷰 유령 프레임 방지 — 뒤집기는 컷으로
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mm__ghost--a .mm__ghostInner { animation-delay: 100ms; }
.mm__ghost--b .mm__ghostInner { animation-delay: 340ms; }
.mm__ghost--c .mm__ghostInner { animation-delay: 1100ms; }
.mm__ghost--d .mm__ghostInner { animation-delay: 1340ms; }
.mm__ghost--a .mm__face--front,
.mm__ghost--b .mm__face--front { color: $error; } // 하트 짝
.mm__ghost--c .mm__face--front,
.mm__ghost--d .mm__face--front { color: $subject-orange-deep; } // 별 짝
// 하이라이트 펄스 — 짝을 이루는 두 장이 같은 순간에 뛴다(래퍼와 이너가 요소가 달라 transform 이 충돌하지 않는다)
.mm__ghost--a, .mm__ghost--b {
animation-name: mmGhostPulse1; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.mm__ghost--c, .mm__ghost--d {
animation-name: mmGhostPulse2; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes mmGhostFlip {
0% { transform: rotateY(0deg); }
3% { transform: rotateY(180deg); }
90% { transform: rotateY(180deg); }
94% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
@keyframes mmGhostPulse1 {
0%, 27% { transform: scale(1); }
38% { transform: scale(1.1); }
51%, 100% { transform: scale(1); }
}
@keyframes mmGhostPulse2 {
0%, 78% { transform: scale(1); }
86% { transform: scale(1.1); }
97%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.mm { gap: $sp-1; }
.mm__grid { gap: $sp-1; }
.mm__stats { gap: $sp-2; }
}
// reduce 에서는 뒤집기·펄스를 끈다 — 카드는 즉시 뒤집히고 매치 표시는 정적 테두리로만
@media (prefers-reduced-motion: reduce) {
.mm__inner { transition: none !important; }
.mm__card.is-matched { animation: none !important; }
.mm__clear b { animation: none !important; }
.mm.is-demo .mm__ghost,
.mm.is-demo .mm__ghostInner { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mm {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 280px;
height: 100%;
}
.mm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mm__title {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 15px);
color: #17141a;
}
.mm__restart {
display: grid;
place-items: center;
width: clamp(24px, 5vw, 28px);
aspect-ratio: 1;
padding: 0;
border: 2px solid rgba(23, 20, 26, 0.32);
border-radius: 12px;
background: none;
color: #17141a;
cursor: pointer;
}
.mm__restart svg {
width: 62%;
height: 62%;
}
.mm__restart:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mm__stats {
display: flex;
align-items: baseline;
gap: 12px;
}
.mm__stat {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.mm__num {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
color: #17141a;
}
.mm__label {
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
color: rgba(23, 20, 26, 0.72);
}
.mm__trophy {
width: clamp(12px, 3vw, 14px);
height: clamp(12px, 3vw, 14px);
color: #c93000;
}
.mm__grid {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template: repeat(3, 1fr)/repeat(4, 1fr);
gap: 8px;
}
.mm__card {
position: relative;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.mm__card:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mm__inner {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mm__card.is-open .mm__inner,
.mm__card.is-matched .mm__inner {
transform: rotateY(180deg);
}
.mm__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: grid;
place-items: center;
border-radius: 8px;
}
.mm__face svg {
width: clamp(18px, 6vw, 26px);
height: clamp(18px, 6vw, 26px);
}
.mm__face--back {
background-color: #fff7e6;
border: 2px solid rgba(23, 20, 26, 0.18);
color: #0a4fe8;
}
.mm__face--front {
background-color: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.14);
transform: rotateY(180deg);
}
.mm__card[data-face=star] .mm__face--front,
.mm__card[data-face=lightning] .mm__face--front {
color: #c93000;
}
.mm__card[data-face=heart] .mm__face--front {
color: #c2361a;
}
.mm__card[data-face=moon] .mm__face--front,
.mm__card[data-face=flower] .mm__face--front {
color: #0a4fe8;
}
.mm__card[data-face=rocket] .mm__face--front {
color: #17141a;
}
.mm__card.is-matched {
animation-name: mmPop;
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
.mm__card.is-matched .mm__face--front {
border-color: #0a4fe8;
box-shadow: 0 0 0 3px rgba(10, 79, 232, 0.22);
}
@keyframes mmPop {
0% {
transform: scale(1);
}
45% {
transform: scale(1.09);
}
100% {
transform: scale(1);
}
}
.mm__clear {
position: absolute;
inset: 0;
z-index: 6;
display: grid;
place-items: center;
margin: 0;
background-color: rgba(23, 20, 26, 0.55);
border-radius: 8px;
}
.mm__clear[hidden] {
display: none;
}
.mm__clear b {
display: inline-block;
padding: 8px 16px;
border-radius: 999px;
background-color: #0a4fe8;
color: #fff7e6;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 13px);
animation-name: mmClearIn;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: both;
}
@keyframes mmClearIn {
from {
opacity: 0;
transform: scale(0.82);
}
to {
opacity: 1;
transform: scale(1);
}
}
.mm__ghost {
display: none;
position: absolute;
z-index: 4;
pointer-events: none;
}
.mm.is-demo .mm__ghost {
display: block;
}
.mm__ghost--a {
grid-area: 1/2;
}
.mm__ghost--b {
grid-area: 3/3;
}
.mm__ghost--c {
grid-area: 1/4;
}
.mm__ghost--d {
grid-area: 2/1;
}
.mm__ghostInner {
position: absolute;
inset: 0;
transform-style: preserve-3d;
animation-name: mmGhostFlip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mm__ghost--a .mm__ghostInner {
animation-delay: 100ms;
}
.mm__ghost--b .mm__ghostInner {
animation-delay: 340ms;
}
.mm__ghost--c .mm__ghostInner {
animation-delay: 1100ms;
}
.mm__ghost--d .mm__ghostInner {
animation-delay: 1340ms;
}
.mm__ghost--a .mm__face--front,
.mm__ghost--b .mm__face--front {
color: #c2361a;
}
.mm__ghost--c .mm__face--front,
.mm__ghost--d .mm__face--front {
color: #c93000;
}
.mm__ghost--a, .mm__ghost--b {
animation-name: mmGhostPulse1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mm__ghost--c, .mm__ghost--d {
animation-name: mmGhostPulse2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mmGhostFlip {
0% {
transform: rotateY(0deg);
}
3% {
transform: rotateY(180deg);
}
90% {
transform: rotateY(180deg);
}
94% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(0deg);
}
}
@keyframes mmGhostPulse1 {
0%, 27% {
transform: scale(1);
}
38% {
transform: scale(1.1);
}
51%, 100% {
transform: scale(1);
}
}
@keyframes mmGhostPulse2 {
0%, 78% {
transform: scale(1);
}
86% {
transform: scale(1.1);
}
97%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.mm {
gap: 4px;
}
.mm__grid {
gap: 4px;
}
.mm__stats {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.mm__inner {
transition: none !important;
}
.mm__card.is-matched {
animation: none !important;
}
.mm__clear b {
animation: none !important;
}
.mm.is-demo .mm__ghost,
.mm.is-demo .mm__ghostInner {
animation: none !important;
}
}// 짝 맞추기 — 카드 12장(6쌍)은 HTML 에 정적으로 있고 JS 가 불러오자마자 자리를 섞는다.
// 상태기계: 첫 장 오픈 → 둘째 장 오픈 → 같으면 매치 고정, 다르면 700ms 뒤 복귀.
// 최고 기록은 최소 시도 수(p301-02-best)다.
(function () {
var KEY = 'p301-02-best';
var CLEAR_TEXT = '클리어! 시도 {n}회 · {t}초';
var NEW_BEST = '최고 기록 갱신!';
var root = document.getElementById('mm');
var grid = document.getElementById('mmGrid');
var triesEl = document.getElementById('mmTries');
var pairsEl = document.getElementById('mmPairs');
var bestEl = document.getElementById('mmBest');
var clearEl = document.getElementById('mmClear');
var clearMsgEl = document.getElementById('mmClearMsg');
var first = null, lock = false, tries = 0, left = 6, startAt = 0;
function loadBest() {
try { return Number(window.localStorage.getItem(KEY)) || 0; } catch (e) { return 0; }
}
function saveBest(v) {
try { window.localStorage.setItem(KEY, String(v)); } catch (e) { /* 저장소가 막힌 창은 건너뛴다 */ }
}
var best = loadBest();
if (best) { bestEl.textContent = String(best); }
function shuffle() {
var cards = [].slice.call(grid.querySelectorAll('.mm__card'));
for (var i = cards.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = cards[i]; cards[i] = cards[j]; cards[j] = t;
}
for (var k = 0; k < cards.length; k++) { grid.appendChild(cards[k]); }
}
function reset() {
first = null; lock = false; tries = 0; left = 6; startAt = 0;
triesEl.textContent = '0';
pairsEl.textContent = '6';
clearEl.hidden = true;
var cards = grid.querySelectorAll('.mm__card');
for (var i = 0; i < cards.length; i++) {
cards[i].classList.remove('is-open', 'is-matched');
cards[i].disabled = false;
cards[i].setAttribute('aria-pressed', 'false');
}
shuffle();
}
function finish() {
var t = Math.round((Date.now() - startAt) / 100) / 10; // 초 단위 한 자리
var msg = CLEAR_TEXT.replace('{n}', String(tries)).replace('{t}', String(t));
if (!best || tries < best) {
best = tries; saveBest(best); bestEl.textContent = String(best);
msg = msg + ' ' + NEW_BEST;
}
clearMsgEl.textContent = msg;
clearEl.hidden = false;
}
grid.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.mm__card') : null;
if (!btn || btn.disabled || lock || btn.classList.contains('is-open')) { return; }
root.classList.remove('is-demo');
if (!startAt) { startAt = Date.now(); }
btn.classList.add('is-open');
btn.setAttribute('aria-pressed', 'true');
if (!first) { first = btn; return; }
var a = first; first = null; lock = true;
tries++; triesEl.textContent = String(tries);
if (a.getAttribute('data-face') === btn.getAttribute('data-face')) {
setTimeout(function () {
a.classList.add('is-matched'); btn.classList.add('is-matched');
a.disabled = true; btn.disabled = true;
left--; pairsEl.textContent = String(left);
lock = false;
if (left === 0) { finish(); }
}, 350);
} else {
setTimeout(function () {
a.classList.remove('is-open'); btn.classList.remove('is-open');
a.setAttribute('aria-pressed', 'false'); btn.setAttribute('aria-pressed', 'false');
lock = false;
}, 700);
}
});
document.getElementById('mmRestart').addEventListener('click', function () {
root.classList.remove('is-demo');
reset();
});
shuffle();
})();// MemoryPairFlip — vanilla 판(run/301/demo/02-memory-pair-flip/index.html) 의 React 포트.
// 카드 12장(6쌍)을 상태로 들고 섞고, 두 장 오픈 → 같으면 매치 고정, 다르면 700ms 뒤 복귀.
// 뒤집기는 CSS(rotateY + preserve-3d + backface-visibility)가 하고 React 는 상태만 바꾼다.
// 최고 기록은 최소 시도 수(p301-02-best).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MemoryPairFlip.module.scss";
export type MemoryPairFlipProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FaceKey = "star" | "heart" | "moon" | "rocket" | "lightning" | "flower";
type TimerBox = { current: number[] }; // ambient 의 useRef 는 T|null 을 돌려주므로 단언용
const KEY = "p301-02-best";
const TOTAL = 12;
const TITLE = "짝 맞추기 카드";
const RESTART_LABEL = "다시 섞기";
const TRIES_LABEL = "시도";
const PAIRS_LABEL = "남은 짝";
const BEST_LABEL = "최고 기록";
const CARD_LABEL = "{n}번 카드";
const CLEAR_TEXT = "클리어! 시도 {n}회 · {t}초";
const NEW_BEST = "최고 기록 갱신!";
// Phosphor Duotone(무료 아이콘 세트) 경로 — [20% 채움 경로, 본 경로]
const BACK_PATHS: [string, string] = [
"m194.82 151.43l-55.09 20.3l-20.3 55.09a7.92 7.92 0 0 1-14.86 0l-20.3-55.09l-55.09-20.3a7.92 7.92 0 0 1 0-14.86l55.09-20.3l20.3-55.09a7.92 7.92 0 0 1 14.86 0l20.3 55.09l55.09 20.3a7.92 7.92 0 0 1 0 14.86",
"M197.58 129.06L146 110l-19-51.62a15.92 15.92 0 0 0-29.88 0L78 110l-51.62 19a15.92 15.92 0 0 0 0 29.88L78 178l19 51.62a15.92 15.92 0 0 0 29.88 0L146 178l51.62-19a15.92 15.92 0 0 0 0-29.88ZM137 164.22a8 8 0 0 0-4.74 4.74L112 223.85L91.78 169a8 8 0 0 0-4.78-4.78L32.15 144L87 123.78a8 8 0 0 0 4.78-4.78L112 64.15L132.22 119a8 8 0 0 0 4.74 4.74L191.85 144ZM144 40a8 8 0 0 1 8-8h16V16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 0 16h-16v16a8 8 0 0 1-16 0V48h-16a8 8 0 0 1-8-8m104 48a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8",
];
const FACE_PATHS: Record<FaceKey, [string, string]> = {
star: [
"m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79",
"M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5",
],
heart: [
"M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54",
"M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8",
],
moon: [
"M227.89 147.89A96 96 0 1 1 108.11 28.11a96.09 96.09 0 0 0 119.78 119.78",
"M233.54 142.23a8 8 0 0 0-8-2a88.08 88.08 0 0 1-109.8-109.8a8 8 0 0 0-10-10a104.84 104.84 0 0 0-52.91 37A104 104 0 0 0 136 224a103.1 103.1 0 0 0 62.52-20.88a104.84 104.84 0 0 0 37-52.91a8 8 0 0 0-1.98-7.98m-44.64 48.11A88 88 0 0 1 65.66 67.11a89 89 0 0 1 31.4-26A106 106 0 0 0 96 56a104.11 104.11 0 0 0 104 104a106 106 0 0 0 14.92-1.06a89 89 0 0 1-26.02 31.4",
],
rocket: [
"m94.81 192l-29.45 22.24a8 8 0 0 1-12.81-4.51L40.19 154.1a8 8 0 0 1 1.66-6.86l30.31-36.33C71 134.25 76.7 161.43 94.81 192m119.34-44.76l-30.31-36.33c1.21 23.34-4.54 50.52-22.65 81.09l29.45 22.24a8 8 0 0 0 12.81-4.51l12.36-55.63a8 8 0 0 0-1.66-6.86",
"M152 224a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8m-24-112a12 12 0 1 0-12-12a12 12 0 0 0 12 12m95.62 43.83l-12.36 55.63a16 16 0 0 1-25.51 9.11L158.51 200h-61l-27.26 20.57a16 16 0 0 1-25.51-9.11l-12.36-55.63a16.09 16.09 0 0 1 3.32-13.71l28.56-34.26a123 123 0 0 1 8.57-36.67c12.9-32.34 36-52.63 45.37-59.85a16 16 0 0 1 19.6 0c9.34 7.22 32.47 27.51 45.37 59.85a123 123 0 0 1 8.57 36.67l28.56 34.26a16.09 16.09 0 0 1 3.32 13.71M99.43 184h57.14c21.12-37.54 25.07-73.48 11.74-106.88C156.55 47.64 134.49 29 128 24c-6.51 5-28.57 23.64-40.33 53.12c-13.31 33.4-9.36 69.34 11.76 106.88m-15 5.85q-16.15-29.35-19.6-57.69L48 152.36L60.36 208l.18-.13ZM208 152.36l-16.83-20.2q-3.42 28.28-19.56 57.69l23.85 18l.18.13Z",
],
lightning: [
"m96 240l16-80l-64-24L160 16l-16 80l64 24Z",
"M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z",
],
flower: [
"M206.35 136.29c-8.87-5.13-24.46-7.38-39.4-8.29c14.94-.91 30.53-3.16 39.4-8.29a32 32 0 1 0-32-55.42c-8.87 5.12-18.61 17.48-26.87 30C154.17 80.87 160 66.25 160 56a32 32 0 0 0-64 0c0 10.25 5.83 24.87 12.52 38.26c-8.26-12.49-18-24.85-26.87-30a32 32 0 1 0-32 55.42c8.87 5.13 24.46 7.38 39.4 8.29c-14.94.91-30.53 3.16-39.4 8.29a32 32 0 1 0 32 55.42c8.87-5.12 18.61-17.48 26.87-30C101.83 175.13 96 189.75 96 200a32 32 0 0 0 64 0c0-10.25-5.83-24.87-12.52-38.26c8.26 12.49 18 24.85 26.87 30a32 32 0 1 0 32-55.42ZM155.71 144a32 32 0 1 1 4.29-16a31.74 31.74 0 0 1-4.29 16",
"M210.35 129.36c-.81-.47-1.7-.92-2.62-1.36c.92-.44 1.81-.89 2.62-1.36a40 40 0 1 0-40-69.28c-.81.47-1.65 1-2.48 1.59c.08-1 .13-2 .13-3a40 40 0 0 0-80 0c0 .94 0 1.94.13 3c-.83-.57-1.67-1.12-2.48-1.59a40 40 0 1 0-40 69.28c.81.47 1.7.92 2.62 1.36c-.92.44-1.81.89-2.62 1.36a40 40 0 1 0 40 69.28c.81-.47 1.65-1 2.48-1.59c-.08 1-.13 1.94-.13 2.95a40 40 0 0 0 80 0c0-.94-.05-1.94-.13-2.95c.83.57 1.67 1.12 2.48 1.59a39.8 39.8 0 0 0 19.94 5.36a40.4 40.4 0 0 0 10.42-1.38a40 40 0 0 0 9.64-73.28ZM104 128a24 24 0 1 1 24 24a24 24 0 0 1-24-24m74.35-56.79a24 24 0 1 1 24 41.57c-6.27 3.63-18.61 6.13-35.16 7.19a40 40 0 0 0-12.66-21.87c9.2-13.82 17.55-23.26 23.82-26.89M128 32a24 24 0 0 1 24 24c0 7.24-4 19.19-11.36 34.06a39.8 39.8 0 0 0-25.28 0C108.36 75.19 104 63.24 104 56a24 24 0 0 1 24-24M44.86 80a24 24 0 0 1 32.79-8.79c6.27 3.63 14.62 13.07 23.82 26.89A40 40 0 0 0 88.81 120c-16.55-1.06-28.89-3.56-35.16-7.18A24 24 0 0 1 44.86 80m32.79 104.79a24 24 0 1 1-24-41.57c6.27-3.63 14.62-6.13 35.16-7.19a40 40 0 0 0 12.66 21.87c-9.2 13.82-17.55 23.26-23.82 26.89M128 224a24 24 0 0 1-24-24c0-7.24 4-19.19 11.36-34.06a39.8 39.8 0 0 0 25.28 0C148.36 180.81 152 192.76 152 200a24 24 0 0 1-24 24m83.14-48a24 24 0 0 1-32.79 8.79c-6.27-3.63-14.62-13.07-23.82-26.89a40 40 0 0 0 12.66-21.9c16.55 1.06 28.89 3.56 35.16 7.18a24 24 0 0 1 8.79 32.82",
],
};
function DuotoneIcon({ paths }: { paths: [string, string] }) {
return (
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d={paths[0]} opacity=".2" />
<path d={paths[1]} />
</g>
</svg>
);
}
function shuffled(): FaceKey[] {
const deck: FaceKey[] = ["star", "heart", "moon", "rocket", "lightning", "flower", "star", "heart", "moon", "rocket", "lightning", "flower"];
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = deck[i]; deck[i] = deck[j]; deck[j] = t;
}
return deck;
}
export default function MemoryPairFlip({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#0a4fe8",
}: MemoryPairFlipProps) {
const [deck, setDeck] = useState<FaceKey[]>(shuffled);
const [open, setOpen] = useState<number[]>([]);
const [matched, setMatched] = useState<boolean[]>(Array(TOTAL).fill(false));
const [tries, setTries] = useState(0);
const [best, setBest] = useState(0);
const [demo, setDemo] = useState(true);
const [clearText, setClearText] = useState<string | null>(null);
const lockRef = useRef(false);
const startRef = useRef(0);
const timersRef = useRef<number[]>([]) as TimerBox;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
try {
const v = Number(window.localStorage.getItem(KEY)) || 0;
if (v) { setBest(v); }
} catch (e) { /* 저장소가 막힌 창은 기본값 그대로 */ }
return () => { timersRef.current.forEach((t) => window.clearTimeout(t)); };
}, []);
// 전부 맞추면 클리어 문구와 최고 기록을 확정한다
useEffect(() => {
if (!matched.length || !matched.every(Boolean)) { return; }
const t = Math.round((Date.now() - (startRef.current || Date.now())) / 100) / 10;
let msg = CLEAR_TEXT.replace("{n}", String(tries)).replace("{t}", String(t));
if (!best || tries < best) {
setBest(tries);
try { window.localStorage.setItem(KEY, String(tries)); } catch (e) { /* 저장 건너뛰기 */ }
msg = msg + " " + NEW_BEST;
}
setClearText(msg);
}, [matched]);
function onCard(i: number) {
if (lockRef.current || matched[i] || open.indexOf(i) >= 0 || clearText) { return; }
setDemo(false);
if (!startRef.current) { startRef.current = Date.now(); }
if (open.length === 0) { setOpen([i]); return; }
const a = open[0];
setOpen([a, i]);
setTries(tries + 1);
lockRef.current = true;
if (deck[a] === deck[i]) {
timersRef.current.push(window.setTimeout(() => {
setMatched((prev) => prev.map((v, k) => (k === a || k === i ? true : v)));
setOpen([]);
lockRef.current = false;
}, 350));
} else {
timersRef.current.push(window.setTimeout(() => {
setOpen([]);
lockRef.current = false;
}, 700));
}
}
function restart() {
timersRef.current.forEach((t) => window.clearTimeout(t));
timersRef.current = [];
lockRef.current = false; startRef.current = 0;
setDemo(false);
setDeck(shuffled());
setOpen([]);
setMatched(Array(TOTAL).fill(false));
setTries(0);
setClearText(null);
}
const left = matched.filter((v) => !v).length / 2;
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const cardCls = (i: number): string =>
[styles.card, open.indexOf(i) >= 0 ? styles.isOpen : "", matched[i] ? styles.isMatched : ""]
.filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>{TITLE}</p>
<button className={styles.restart} type="button" aria-label={RESTART_LABEL} onClick={restart}>
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<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>
</svg>
</button>
</header>
<div className={styles.stats}>
<span className={styles.stat}><b className={styles.num}>{tries}</b><span className={styles.label}>{TRIES_LABEL}</span></span>
<span className={styles.stat}><b className={styles.num}>{left}</b><span className={styles.label}>{PAIRS_LABEL}</span></span>
<span className={styles.statBest} aria-label={BEST_LABEL}>
<svg className={styles.trophy} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<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>
</svg>
<b className={styles.num}>{best || "-"}</b>
</span>
</div>
<div className={styles.grid}>
{deck.map((face, i) => (
<button
key={i}
className={cardCls(i)}
type="button"
data-face={face}
aria-label={CARD_LABEL.replace("{n}", String(i + 1))}
aria-pressed={open.indexOf(i) >= 0 || matched[i] ? "true" : "false"}
disabled={matched[i]}
onClick={() => onCard(i)}
>
<span className={styles.inner}>
<span className={styles.faceBack}><DuotoneIcon paths={BACK_PATHS} /></span>
<span className={styles.faceFront} data-face={face}><DuotoneIcon paths={FACE_PATHS[face]} /></span>
</span>
</button>
))}
<span className={`${styles.ghost} ${styles.ghostA}`} aria-hidden="true">
<span className={styles.ghostInner}>
<span className={styles.faceBack}><DuotoneIcon paths={BACK_PATHS} /></span>
<span className={styles.faceFront}><DuotoneIcon paths={FACE_PATHS.heart} /></span>
</span>
</span>
<span className={`${styles.ghost} ${styles.ghostB}`} aria-hidden="true">
<span className={styles.ghostInner}>
<span className={styles.faceBack}><DuotoneIcon paths={BACK_PATHS} /></span>
<span className={styles.faceFront}><DuotoneIcon paths={FACE_PATHS.heart} /></span>
</span>
</span>
<span className={`${styles.ghost} ${styles.ghostC}`} aria-hidden="true">
<span className={styles.ghostInner}>
<span className={styles.faceBack}><DuotoneIcon paths={BACK_PATHS} /></span>
<span className={styles.faceFront}><DuotoneIcon paths={FACE_PATHS.star} /></span>
</span>
</span>
<span className={`${styles.ghost} ${styles.ghostD}`} aria-hidden="true">
<span className={styles.ghostInner}>
<span className={styles.faceBack}><DuotoneIcon paths={BACK_PATHS} /></span>
<span className={styles.faceFront}><DuotoneIcon paths={FACE_PATHS.star} /></span>
</span>
</span>
<p className={styles.clear} role="status" hidden={clearText === null}>
<b>{clearText || CLEAR_TEXT}</b>
</p>
</div>
</div>
</main>
);
}02 · 짝 맞추기 카드 — 측정 누적면적 1.367% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
앞뒤 두 면을 한 상자에 겹쳐 두기
.mm__inner {
position: absolute; inset: 0;
transform-style: preserve-3d;
transition: transform $dur-base $easing;
}
.mm__card.is-open .mm__inner,
.mm__card.is-matched .mm__inner { transform: rotateY(180deg); }
.mm__face {
position: absolute; inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
display: grid; place-items: center;
border-radius: $r-sm;
}
앞뒷면을 같은 자리에 포개고 backface-visibility: hidden 으로 등을 돌린 면을 지운 뒤 상자만 180도 돌리므로, 뒤집기에 필요한 JS 는 클래스 한 개를 붙였다 떼는 일뿐입니다.
같으면 고정 다르면 700ms 뒤 복귀
if (a.getAttribute('data-face') === btn.getAttribute('data-face')) {
setTimeout(function () {
a.classList.add('is-matched'); btn.classList.add('is-matched');
a.disabled = true; btn.disabled = true;
left--; pairsEl.textContent = String(left);
lock = false;
if (left === 0) { finish(); }
}, 350);
} else {
setTimeout(function () {
a.classList.remove('is-open'); btn.classList.remove('is-open');
a.setAttribute('aria-pressed', 'false'); btn.setAttribute('aria-pressed', 'false');
lock = false;
}, 700);
}
두 장이 열려 있는 동안 lock 이 세 번째 클릭을 막아 주고, 뒤집기가 컷 연출이라 이 판의 누적면적은 1.367%로 아홉 중 가장 낮습니다.
03두더지 잡기
3×3 구멍에서 두더지가 무작위로 솟고, 솟아 있는 동안 눌러야 점수가 오르는, 이벤트 페이지의 경품 룰렛 대용이나 아이 눈길을 끄는 섹션에 맞는 판입니다.

<main class="stage">
<div class="wg is-demo" id="wg">
<header class="wg__bar">
<p class="wg__title">두더지 잡기</p>
<span class="wg__best" aria-label="최고 기록">
<svg class="wg__trophy" viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><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></svg>
<b class="wg__num" id="wgBest">0</b>
</span>
<button class="wg__restart" id="wgRestart" type="button" aria-label="다시 시작">
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><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></svg>
</button>
</header>
<div class="wg__meter">
<span class="wg__clock">
<svg class="wg__timer" viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M216 136a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M128 40a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80m45.66-125.66a8 8 0 0 1 0 11.32l-40 40a8 8 0 0 1-11.32-11.32l40-40a8 8 0 0 1 11.32 0M96 16a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8"/></g></svg>
<b class="wg__num" id="wgClock">20.0</b><span class="wg__unit">초</span>
</span>
<span class="wg__track"><span class="wg__fill" id="wgFill"></span></span>
<span class="wg__score"><b class="wg__num wg__num--score" id="wgScore">0</b><span class="wg__unit">점</span></span>
</div>
<div class="wg__field" id="wgField">
<button class="wg__hole" type="button" aria-label="1번 구멍" style="--i: 0">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="2번 구멍" style="--i: 1">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="3번 구멍" style="--i: 2">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="4번 구멍" style="--i: 3">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="5번 구멍" style="--i: 4">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="6번 구멍" style="--i: 5">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="7번 구멍" style="--i: 6">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="8번 구멍" style="--i: 7">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<button class="wg__hole" type="button" aria-label="9번 구멍" style="--i: 8">
<span class="wg__window"><span class="wg__mole"><svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188" opacity=".2"/><path d="M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188"/></g></svg></span></span>
<span class="wg__rim" aria-hidden="true"></span>
<span class="wg__flash" aria-hidden="true"></span>
</button>
<div class="wg__veil" id="wgVeil">
<p class="wg__veilMsg" id="wgVeilMsg" role="status" hidden><b id="wgEndMsg">게임 끝! 점수 {n} · 최고 {b}</b></p>
<button class="wg__start" id="wgStart" type="button">시작</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두더지 잡기 — 3×3 구멍에서 두더지가 무작위로 솟았다 들어간다.
// 실사용 경로: JS 가 구멍에 .is-up 을 붙였다 떼고, 솟고 들어가는 움직임은 transition 이 그린다.
// 격자 미리보기(.is-demo): 같은 움직임을 @keyframes 로 그리되 구멍마다 음수 animation-delay 를 달아
// 위상만 어긋뜨린다. 주기가 전부 2초라 gif 가 한 바퀴에서 매끈하게 이어진다(271 계열 함정 회피).
// 실사용 코드와 미리보기 코드가 갈라져 있는 것은 의도다 — 252-07 과 같은 계열.
// 무대가 주황이라 무대 위 글자는 전부 $subject-ink 다(5.50:1). 크림 글자는 3.11:1 로 AA 미달이라 쓰지 않고,
// 알파를 씌운 잉크(.72)도 주황 위에서는 3.77:1 이라 못 쓴다 — 보조 글자는 굵기·크기로만 낮춘다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
// 구멍에는 글자가 없어 내용 높이가 0 이다 — 칸을 세로로 채우지 않으면 1fr 행이 통째로 접힌다
.stage { align-items: stretch; }
.wg {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 300px; height: 100%;
}
// ── 머리줄 ───────────────────────────────────────────────
.wg__bar { display: flex; align-items: center; gap: $sp-2; }
.wg__title { flex: 1 1 auto; margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 15px); color: $subject-ink; }
.wg__best { display: inline-flex; align-items: center; gap: $sp-1; color: $subject-ink; }
.wg__trophy { width: clamp(12px, 3vw, 15px); height: clamp(12px, 3vw, 15px); }
.wg__restart {
display: grid; place-items: center; width: clamp(22px, 5vw, 28px); aspect-ratio: 1; padding: 0;
border: 2px solid rgba($subject-ink, .32); border-radius: $r-control;
background: none; color: $subject-ink; cursor: pointer;
}
.wg__restart svg { width: 62%; height: 62%; }
.wg__restart:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 시계·점수줄 ───────────────────────────────────────────
.wg__meter { display: flex; align-items: center; gap: $sp-2; }
.wg__clock { display: inline-flex; align-items: center; gap: $sp-1; color: $subject-ink; }
.wg__timer { width: clamp(11px, 2.8vw, 14px); height: clamp(11px, 2.8vw, 14px); }
.wg__num { font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); color: $subject-ink; font-variant-numeric: tabular-nums; }
.wg__num--score { font-size: clamp(13px, 3.4vw, 17px); }
.wg__unit { font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); color: $subject-ink; }
.wg__score { display: inline-flex; align-items: baseline; gap: $sp-1; }
.wg__track {
flex: 1 1 auto; display: block; height: 8px; overflow: hidden;
border-radius: $r-pill; background-color: rgba($subject-ink, .24);
}
// 채움 막대는 시작 변에서 줄어든다 — 그래서 origin 이 중심이 아니다(blog-demo 규칙 8 의 "붙는 지점" 쪽)
.wg__fill {
display: block; width: 100%; height: 100%; border-radius: inherit;
background-color: $subject-ink; transform-origin: left center; transform: scaleX(1);
}
// ── 구멍판 ───────────────────────────────────────────────
.wg__field {
position: relative; flex: 1 1 auto; min-height: 0;
display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr); gap: $sp-2;
}
.wg__hole {
position: relative; display: block; width: 100%; height: 100%; padding: 0;
border: 0; border-radius: 50%; background: none; cursor: pointer;
}
.wg__hole:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 구멍 안쪽 — 여기서만 잘린다. 두더지는 이 상자 아래에 숨어 있다가 올라온다
.wg__window {
position: absolute; inset: 0; display: block; overflow: hidden;
border-radius: inherit; background-color: $subject-ink;
}
// 구멍 테두리(입구 턱) — 클릭을 가로채지 않는다
.wg__rim {
position: absolute; inset: 0; display: block; pointer-events: none;
border: 2px solid rgba($subject-cream, .18); border-radius: inherit;
}
// 두더지 몸통은 CSS(알약 반쪽)이고 얼굴만 Phosphor Duotone 아이콘이다
.wg__mole {
position: absolute; left: 16%; right: 16%; bottom: 0; height: 76%;
display: grid; place-items: center;
border-radius: $r-pill $r-pill 0 0;
background-color: $color; color: $subject-ink;
transform: translateY(100%);
transition: transform $dur-quick $easing;
}
.wg__mole svg { width: 66%; height: 66%; }
.wg__hole.is-up .wg__mole { transform: translateY(0); }
// 명중 플래시 — 구멍 전체를 한 번 밝히고 퍼지며 사라진다(자기 중심에서 커진다)
.wg__flash {
position: absolute; inset: 0; display: block; pointer-events: none;
border-radius: inherit; background-color: $subject-cream; opacity: 0;
}
.wg__hole.is-hit .wg__flash {
animation-name: wgFlashHit; animation-duration: 380ms; animation-timing-function: $ease-out;
animation-iteration-count: 1; animation-fill-mode: both;
}
@keyframes wgFlashHit {
from { opacity: .8; transform: scale(1); }
to { opacity: 0; transform: scale(1.22); }
}
// ── 시작·결과 덮개 ────────────────────────────────────────
.wg__veil {
position: absolute; inset: 0; z-index: 3; padding: $sp-2;
display: grid; place-items: center; align-content: center; gap: $sp-2;
border-radius: $r-card; background-color: rgba($subject-ink, .72);
}
.wg__veil[hidden] { display: none; }
.wg__veilMsg {
margin: 0; text-align: center;
font-weight: 800; font-size: clamp(10px, 2.6vw, 13px); color: $subject-cream;
}
.wg__veilMsg[hidden] { display: none; }
.wg__start {
padding: $sp-2 $sp-5; border: 0; border-radius: $r-pill;
background-color: $subject-cream; color: $subject-ink;
font-weight: 800; font-size: clamp(11px, 2.8vw, 14px); cursor: pointer;
box-shadow: $shadow-press; transition: transform $dur-instant $ease-out;
}
.wg__start:active { transform: scale(.94); }
.wg__start:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 격자 미리보기 ─────────────────────────────────────────
// 구멍 아홉에 -220ms 씩 어긋난 지연을 주면 두더지가 읽는 순서대로 물결처럼 솟는다.
// 지연이 음수라 첫 프레임부터 이미 여럿이 올라와 있다 — 격자 썸네일이 빈 화면으로 시작하지 않는다.
.wg.is-demo .wg__veil { opacity: 0; }
.wg.is-demo .wg__mole,
.wg.is-demo .wg__flash {
animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: both;
animation-delay: calc(var(--i) * -220ms);
}
.wg.is-demo .wg__mole { animation-name: wgMoleRise; transition: none; }
.wg.is-demo .wg__flash { animation-name: wgHitLoop; }
@keyframes wgMoleRise {
0% { transform: translateY(100%); }
8% { transform: translateY(0); }
20% { transform: translateY(0); }
30% { transform: translateY(100%); }
100% { transform: translateY(100%); }
}
@keyframes wgHitLoop {
0% { opacity: 0; transform: scale(.92); }
17% { opacity: 0; transform: scale(.92); }
21% { opacity: .8; transform: scale(1); }
34% { opacity: 0; transform: scale(1.18); }
100% { opacity: 0; transform: scale(1.18); }
}
@media (max-width: 340px) {
.wg { gap: $sp-1; }
.wg__field { gap: $sp-1; }
.wg__veil { padding: $sp-1; }
.wg__track { height: 4px; }
}
// reduce 에서는 솟는 연출을 끄고 두더지를 올라온 채로 세워 둔다 — 무엇을 잡는 게임인지는 그대로 보인다.
// .is-demo 가 붙은 규칙(0,3,0)을 이기려면 !important 가 필요하다(260 실측)
@media (prefers-reduced-motion: reduce) {
.wg.is-demo .wg__mole { animation: none !important; transform: translateY(0) !important; }
.wg.is-demo .wg__flash { animation: none !important; opacity: 0 !important; }
.wg__hole.is-hit .wg__flash { animation: none !important; }
.wg__mole { transition: none !important; }
.wg__start { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
align-items: stretch;
}
.wg {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 300px;
height: 100%;
}
.wg__bar {
display: flex;
align-items: center;
gap: 8px;
}
.wg__title {
flex: 1 1 auto;
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 15px);
color: #17141a;
}
.wg__best {
display: inline-flex;
align-items: center;
gap: 4px;
color: #17141a;
}
.wg__trophy {
width: clamp(12px, 3vw, 15px);
height: clamp(12px, 3vw, 15px);
}
.wg__restart {
display: grid;
place-items: center;
width: clamp(22px, 5vw, 28px);
aspect-ratio: 1;
padding: 0;
border: 2px solid rgba(23, 20, 26, 0.32);
border-radius: 12px;
background: none;
color: #17141a;
cursor: pointer;
}
.wg__restart svg {
width: 62%;
height: 62%;
}
.wg__restart:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.wg__meter {
display: flex;
align-items: center;
gap: 8px;
}
.wg__clock {
display: inline-flex;
align-items: center;
gap: 4px;
color: #17141a;
}
.wg__timer {
width: clamp(11px, 2.8vw, 14px);
height: clamp(11px, 2.8vw, 14px);
}
.wg__num {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
color: #17141a;
font-variant-numeric: tabular-nums;
}
.wg__num--score {
font-size: clamp(13px, 3.4vw, 17px);
}
.wg__unit {
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
color: #17141a;
}
.wg__score {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.wg__track {
flex: 1 1 auto;
display: block;
height: 8px;
overflow: hidden;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.24);
}
.wg__fill {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background-color: #17141a;
transform-origin: left center;
transform: scaleX(1);
}
.wg__field {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template: repeat(3, 1fr)/repeat(3, 1fr);
gap: 8px;
}
.wg__hole {
position: relative;
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
cursor: pointer;
}
.wg__hole:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.wg__window {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: inherit;
background-color: #17141a;
}
.wg__rim {
position: absolute;
inset: 0;
display: block;
pointer-events: none;
border: 2px solid rgba(255, 247, 230, 0.18);
border-radius: inherit;
}
.wg__mole {
position: absolute;
left: 16%;
right: 16%;
bottom: 0;
height: 76%;
display: grid;
place-items: center;
border-radius: 999px 999px 0 0;
background-color: #fff7e6;
color: #17141a;
transform: translateY(100%);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.wg__mole svg {
width: 66%;
height: 66%;
}
.wg__hole.is-up .wg__mole {
transform: translateY(0);
}
.wg__flash {
position: absolute;
inset: 0;
display: block;
pointer-events: none;
border-radius: inherit;
background-color: #fff7e6;
opacity: 0;
}
.wg__hole.is-hit .wg__flash {
animation-name: wgFlashHit;
animation-duration: 380ms;
animation-timing-function: ease-out;
animation-iteration-count: 1;
animation-fill-mode: both;
}
@keyframes wgFlashHit {
from {
opacity: 0.8;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(1.22);
}
}
.wg__veil {
position: absolute;
inset: 0;
z-index: 3;
padding: 8px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
border-radius: 16px;
background-color: rgba(23, 20, 26, 0.72);
}
.wg__veil[hidden] {
display: none;
}
.wg__veilMsg {
margin: 0;
text-align: center;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 13px);
color: #fff7e6;
}
.wg__veilMsg[hidden] {
display: none;
}
.wg__start {
padding: 8px 20px;
border: 0;
border-radius: 999px;
background-color: #fff7e6;
color: #17141a;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 14px);
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.wg__start:active {
transform: scale(0.94);
}
.wg__start:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.wg.is-demo .wg__veil {
opacity: 0;
}
.wg.is-demo .wg__mole,
.wg.is-demo .wg__flash {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
animation-delay: calc(var(--i) * -220ms);
}
.wg.is-demo .wg__mole {
animation-name: wgMoleRise;
transition: none;
}
.wg.is-demo .wg__flash {
animation-name: wgHitLoop;
}
@keyframes wgMoleRise {
0% {
transform: translateY(100%);
}
8% {
transform: translateY(0);
}
20% {
transform: translateY(0);
}
30% {
transform: translateY(100%);
}
100% {
transform: translateY(100%);
}
}
@keyframes wgHitLoop {
0% {
opacity: 0;
transform: scale(0.92);
}
17% {
opacity: 0;
transform: scale(0.92);
}
21% {
opacity: 0.8;
transform: scale(1);
}
34% {
opacity: 0;
transform: scale(1.18);
}
100% {
opacity: 0;
transform: scale(1.18);
}
}
@media (max-width: 340px) {
.wg {
gap: 4px;
}
.wg__field {
gap: 4px;
}
.wg__veil {
padding: 4px;
}
.wg__track {
height: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.wg.is-demo .wg__mole {
animation: none !important;
transform: translateY(0) !important;
}
.wg.is-demo .wg__flash {
animation: none !important;
opacity: 0 !important;
}
.wg__hole.is-hit .wg__flash {
animation: none !important;
}
.wg__mole {
transition: none !important;
}
.wg__start {
transition: none !important;
}
}// 두더지 잡기 — 3×3 구멍에 무작위로(700~1000ms 간격) 두더지가 솟고, 솟아 있는 동안 pointerdown 하면 점수.
// 제한 시간 20초. 놓쳐도 감점은 없다. 최고 점수는 p301-03-best.
// 실제 솟기·들기는 transition(부드럽게), 격자 미리보기(.is-demo)만 @keyframes — 두 코드가 갈라진다(252-07 계열).
(function () {
var KEY = 'p301-03-best';
var TIME = 20000; // 제한 시간 20초 (ms)
var UP_MS = 850; // 두더지가 올라와 있는 시간 (ms)
var END_TEXT = '게임 끝! 점수 {n} · 최고 {b}';
var AGAIN = '다시';
var START = '시작';
var root = document.getElementById('wg');
var field = document.getElementById('wgField');
var holes = [].slice.call(field.querySelectorAll('.wg__hole'));
var scoreEl = document.getElementById('wgScore');
var clockEl = document.getElementById('wgClock');
var fillEl = document.getElementById('wgFill');
var bestEl = document.getElementById('wgBest');
var veil = document.getElementById('wgVeil');
var veilMsg = document.getElementById('wgVeilMsg');
var endMsg = document.getElementById('wgEndMsg');
var startBtn = document.getElementById('wgStart');
var playing = false, score = 0, endAt = 0, upIndex = -1, lastHole = -1;
var tickId = 0, spawnId = 0, downId = 0;
function loadBest() {
try { return Number(window.localStorage.getItem(KEY)) || 0; } catch (e) { return 0; }
}
var best = loadBest();
bestEl.textContent = String(best);
function paintTime(ms) {
clockEl.textContent = (ms / 1000).toFixed(1);
fillEl.style.transform = 'scaleX(' + (ms / TIME) + ')'; // 채움 막대 — 시작 변 기준
}
function sink() {
clearTimeout(downId); downId = 0;
if (upIndex >= 0) { holes[upIndex].classList.remove('is-up'); upIndex = -1; }
}
function spawn() {
if (!playing) { return; }
sink();
var i = lastHole;
while (i === lastHole) { i = Math.floor(Math.random() * holes.length); } // 같은 구멍 연속 스폰은 피한다
lastHole = i; upIndex = i;
holes[i].classList.add('is-up');
downId = setTimeout(sink, UP_MS);
spawnId = setTimeout(spawn, 700 + Math.random() * 300);
}
function stopTimers() {
clearInterval(tickId); clearTimeout(spawnId); clearTimeout(downId);
tickId = 0; spawnId = 0; downId = 0;
}
function hit(btn) {
if (!playing) { return; }
var i = holes.indexOf(btn);
if (i !== upIndex) { return; } // 안 올라와 있는 구멍 — 중복·빈 클릭 방지
upIndex = -1; clearTimeout(downId); downId = 0;
score++; scoreEl.textContent = String(score);
btn.classList.remove('is-up');
btn.classList.add('is-hit');
setTimeout(function () { btn.classList.remove('is-hit'); }, 380);
}
function tick() {
var left = endAt - Date.now();
if (left <= 0) { finish(); return; }
paintTime(left);
}
function finish() {
playing = false; stopTimers(); sink();
paintTime(0);
if (score > best) {
best = score;
try { window.localStorage.setItem(KEY, String(best)); } catch (e) { /* 저장 건너뛰기 */ }
bestEl.textContent = String(best);
}
endMsg.textContent = END_TEXT.replace('{n}', String(score)).replace('{b}', String(best));
veilMsg.hidden = false;
startBtn.textContent = AGAIN;
veil.hidden = false;
startBtn.focus({ preventScroll: true }); // iframe 안에서 부모 스크롤 방지
}
function start() {
root.classList.remove('is-demo');
playing = true; score = 0; lastHole = -1;
scoreEl.textContent = '0';
veil.hidden = true; veilMsg.hidden = true;
endAt = Date.now() + TIME;
paintTime(TIME);
tickId = setInterval(tick, 100);
spawnId = setTimeout(spawn, 350);
}
field.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
var btn = e.target.closest ? e.target.closest('.wg__hole') : null;
if (btn) { hit(btn); }
});
field.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') { return; }
var btn = e.target.closest ? e.target.closest('.wg__hole') : null;
if (btn) { e.preventDefault(); hit(btn); }
});
startBtn.addEventListener('click', start);
document.getElementById('wgRestart').addEventListener('click', function () {
root.classList.remove('is-demo');
playing = false; stopTimers(); sink();
score = 0; scoreEl.textContent = '0';
paintTime(TIME);
startBtn.textContent = START;
veilMsg.hidden = true;
veil.hidden = false;
});
paintTime(TIME);
})();// MoleWhackTimer — vanilla 판(run/301/demo/03-mole-whack-timer/index.html) 의 React 포트.
// 3×3 구멍에 무작위로(700~1000ms 간격) 두더지가 솟고, 솟아 있는 동안 pointerdown 하면 점수.
// 제한 시간 20초. 최고 점수는 p301-03-best.
// 솟고 들어가는 움직임은 CSS(transition / .is-demo 에서는 @keyframes)가 그리고 React 는 상태만 바꾼다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MoleWhackTimer.module.scss";
export type MoleWhackTimerProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type TimerBox = { current: number }; // ambient 의 useRef 는 T|null 을 돌려주므로 단언용
type HitBox = { current: number[] };
// ambient.d.ts 가 React 이벤트 제네릭을 안 주므로 로컬 타입로 쓴다(237 계열)
type PointerEvt = { target: { closest: (s: string) => HTMLElement | null } };
type KeyEvt = { key: string; preventDefault(): void; target: { closest: (s: string) => HTMLElement | null } };
const KEY = "p301-03-best";
const TIME = 20000; // 제한 시간 20초 (ms)
const UP_MS = 850; // 두더지가 올라와 있는 시간 (ms)
const HOLES = 9;
const TITLE = "두더지 잡기";
const BEST_LABEL = "최고 기록";
const RESTART_LABEL = "다시 시작";
const HOLE_LABEL = "{n}번 구멍";
const UNIT_SEC = "초";
const UNIT_PTS = "점";
const END_TEXT = "게임 끝! 점수 {n} · 최고 {b}";
const START = "시작";
const AGAIN = "다시";
// Phosphor Duotone(무료 아이콘 세트) 경로 — [20% 채움 경로, 본 경로]
const MOLE_PATHS: [string, string] = [
"M208 188a44 44 0 0 1-80 25.3a44 44 0 1 1-63.71-59.48a64 64 0 0 1 127.42 0A43.9 43.9 0 0 1 208 188",
"M112 164a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m60 36a52 52 0 0 1-88 37.52a52 52 0 1 1-71.28-75.72A71.6 71.6 0 0 1 63 129C44 91.1 32.53 40.76 45.64 19.08A22 22 0 0 1 65.06 8c14.12 0 26 11.89 36.44 36.36c6.22 14.62 10.85 31.32 14 44.74a71.8 71.8 0 0 1 25 0c3.13-13.42 7.76-30.12 14-44.74C164.9 19.89 176.82 8 190.94 8a22 22 0 0 1 19.42 11.08C223.47 40.76 212 91.1 193 129a71.6 71.6 0 0 1 6.26 20.76A51.77 51.77 0 0 1 216 188m-60.11-94.37a71.7 71.7 0 0 1 26.88 19.64a218.5 218.5 0 0 0 14.83-46.19c3.49-18.13 3.15-33-.93-39.72a6 6 0 0 0-5.73-3.36c-6.61 0-14.52 9.7-21.72 26.62c-5.93 13.94-10.35 30.12-13.33 43.01m-82.66 19.64a71.7 71.7 0 0 1 26.88-19.64c-3-12.89-7.4-29.07-13.33-43C79.58 33.7 71.67 24 65.06 24a6 6 0 0 0-5.73 3.36C55.25 34.1 54.91 49 58.4 67.08a218.5 218.5 0 0 0 14.83 46.19M200 188a35.87 35.87 0 0 0-13.34-28a8 8 0 0 1-2.92-5.45a56 56 0 0 0-111.48 0a8 8 0 0 1-2.92 5.45a36 36 0 1 0 47.28 54.21l-9.74-8.09a8 8 0 1 1 10.24-12.3L128 202.9l10.88-9.05a8 8 0 1 1 10.24 12.3l-9.74 8.09A36 36 0 0 0 200 188",
];
const TROPHY_PATHS: [string, string] = [
"M200 48v63.1c0 39.7-31.75 72.6-71.45 72.9A72 72 0 0 1 56 112V48Z",
"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",
];
const RESTART_PATHS: [string, string] = [
"M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88",
"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",
];
const TIMER_PATHS: [string, string] = [
"M216 136a88 88 0 1 1-88-88a88 88 0 0 1 88 88",
"M128 40a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80m45.66-125.66a8 8 0 0 1 0 11.32l-40 40a8 8 0 0 1-11.32-11.32l40-40a8 8 0 0 1 11.32 0M96 16a8 8 0 0 1 8-8h48a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8",
];
function DuotoneIcon({ paths }: { paths: [string, string] }) {
return (
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d={paths[0]} opacity=".2" />
<path d={paths[1]} />
</g>
</svg>
);
}
export default function MoleWhackTimer({
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
accentColor = "#fff7e6",
}: MoleWhackTimerProps) {
const [playing, setPlaying] = useState(false);
const [score, setScore] = useState(0);
const [up, setUp] = useState(-1);
const [hitIndex, setHitIndex] = useState(-1);
const [best, setBest] = useState(0);
const [demo, setDemo] = useState(true);
const [left, setLeft] = useState(TIME);
const [endText, setEndText] = useState<string | null>(null);
const [again, setAgain] = useState(false);
const playingRef = useRef(false) as { current: boolean };
const upRef = useRef(-1) as TimerBox;
const lastHoleRef = useRef(-1) as TimerBox;
const scoreRef = useRef(0) as TimerBox;
const endAtRef = useRef(0) as TimerBox;
const bestRef = useRef(0) as TimerBox;
const tickRef = useRef(0) as TimerBox;
const spawnRef = useRef(0) as TimerBox;
const downRef = useRef(0) as TimerBox;
const hitTimersRef = useRef<number[]>([]) as HitBox;
const startBtnRef = useRef<HTMLButtonElement | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
try {
const v = Number(window.localStorage.getItem(KEY)) || 0;
if (v) { bestRef.current = v; setBest(v); }
} catch (e) { /* 저장소가 막힌 창은 기본값 그대로 */ }
return () => { clearAll(); };
}, []);
function clearAll() {
window.clearInterval(tickRef.current);
window.clearTimeout(spawnRef.current);
window.clearTimeout(downRef.current);
hitTimersRef.current.forEach((t) => window.clearTimeout(t));
tickRef.current = 0; spawnRef.current = 0; downRef.current = 0;
hitTimersRef.current = [];
}
function sink() {
if (downRef.current) { window.clearTimeout(downRef.current); downRef.current = 0; }
if (upRef.current >= 0) { upRef.current = -1; setUp(-1); }
}
function spawn() {
if (!playingRef.current) { return; }
sink();
let i = lastHoleRef.current;
while (i === lastHoleRef.current) { i = Math.floor(Math.random() * HOLES); } // 같은 구멍 연속 스폰은 피한다
lastHoleRef.current = i; upRef.current = i; setUp(i);
downRef.current = window.setTimeout(sink, UP_MS);
spawnRef.current = window.setTimeout(spawn, 700 + Math.random() * 300);
}
function tick() {
const remain = endAtRef.current - Date.now();
if (remain <= 0) { finish(); return; }
setLeft(remain);
}
function finish() {
playingRef.current = false;
clearAll(); sink();
setLeft(0);
const s = scoreRef.current;
let b = bestRef.current;
if (s > b) {
b = s; bestRef.current = b; setBest(b);
try { window.localStorage.setItem(KEY, String(b)); } catch (e) { /* 저장 건너뛰기 */ }
}
setEndText(END_TEXT.replace("{n}", String(s)).replace("{b}", String(b)));
setAgain(true);
setPlaying(false);
if (startBtnRef.current) { startBtnRef.current.focus({ preventScroll: true }); } // iframe 안에서 부모 스크롤 방지
}
function start() {
setDemo(false);
playingRef.current = true; scoreRef.current = 0; lastHoleRef.current = -1;
setScore(0); setLeft(TIME); setEndText(null); setPlaying(true);
endAtRef.current = Date.now() + TIME;
tickRef.current = window.setInterval(tick, 100);
spawnRef.current = window.setTimeout(spawn, 350);
}
function hit(i: number) {
if (!playingRef.current || i !== upRef.current) { return; } // 안 올라와 있는 구멍 — 중복·빈 클릭 방지
upRef.current = -1;
if (downRef.current) { window.clearTimeout(downRef.current); downRef.current = 0; }
scoreRef.current += 1; setScore(scoreRef.current);
setUp(-1); setHitIndex(i);
hitTimersRef.current.push(window.setTimeout(() => setHitIndex(-1), 380));
}
function reset() {
setDemo(false);
playingRef.current = false;
clearAll(); sink();
scoreRef.current = 0; setScore(0); setLeft(TIME);
setEndText(null); setAgain(false); setPlaying(false);
}
function onFieldPointerDown(e: PointerEvt) {
setDemo(false);
const btn = e.target.closest ? e.target.closest("button") : null;
if (btn) { hit(Number(btn.getAttribute("data-i"))); }
}
function onFieldKeyDown(e: KeyEvt) {
if (e.key !== "Enter" && e.key !== " ") { return; }
const btn = e.target.closest ? e.target.closest("button") : null;
if (btn) { e.preventDefault(); hit(Number(btn.getAttribute("data-i"))); }
}
const rootCls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const holeCls = (i: number) => (
[styles.hole, up === i ? styles.isUp : "", hitIndex === i ? styles.isHit : ""].filter(Boolean).join(" ")
);
return (
<main className={styles.stage}>
<div className={rootCls} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>{TITLE}</p>
<span className={styles.best} aria-label={BEST_LABEL}>
<svg className={styles.trophy} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d={TROPHY_PATHS[0]} opacity=".2" /><path d={TROPHY_PATHS[1]} /></g>
</svg>
<b className={styles.num}>{best}</b>
</span>
<button className={styles.restart} type="button" aria-label={RESTART_LABEL} onClick={reset}>
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d={RESTART_PATHS[0]} opacity=".2" /><path d={RESTART_PATHS[1]} /></g>
</svg>
</button>
</header>
<div className={styles.meter}>
<span className={styles.clock}>
<svg className={styles.timer} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor"><path d={TIMER_PATHS[0]} opacity=".2" /><path d={TIMER_PATHS[1]} /></g>
</svg>
<b className={styles.num}>{(left / 1000).toFixed(1)}</b><span className={styles.unit}>{UNIT_SEC}</span>
</span>
<span className={styles.track}>
<span className={styles.fill} style={{ transform: `scaleX(${left / TIME})` }}></span>
</span>
<span className={styles.score}>
<b className={[styles.num, styles.numScore].join(" ")}>{score}</b><span className={styles.unit}>{UNIT_PTS}</span>
</span>
</div>
<div className={styles.field} onPointerDown={onFieldPointerDown} onKeyDown={onFieldKeyDown}>
{Array.from({ length: HOLES }, (_, i) => (
<button className={holeCls(i)} type="button" key={i} data-i={i}
aria-label={HOLE_LABEL.replace("{n}", String(i + 1))} style={{ "--i": i } as Vars}>
<span className={styles.window}>
<span className={styles.mole}><DuotoneIcon paths={MOLE_PATHS} /></span>
</span>
<span className={styles.rim} aria-hidden="true"></span>
<span className={styles.flash} aria-hidden="true"></span>
</button>
))}
{!playing && (
<div className={styles.veil}>
{endText && <p className={styles.veilMsg} role="status"><b>{endText}</b></p>}
<button className={styles.start} type="button" ref={startBtnRef} onClick={start}>
{again ? AGAIN : START}
</button>
</div>
)}
</div>
</div>
</main>
);
}03 · 두더지 잡기 — 측정 누적면적 33.16% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
솟고 들어가는 건 transition 한 줄
// 두더지 몸통은 CSS(알약 반쪽)이고 얼굴만 Phosphor Duotone 아이콘이다
.wg__mole {
position: absolute; left: 16%; right: 16%; bottom: 0; height: 76%;
display: grid; place-items: center;
border-radius: $r-pill $r-pill 0 0;
background-color: $color; color: $subject-ink;
transform: translateY(100%);
transition: transform $dur-quick $easing;
}
.wg__mole svg { width: 66%; height: 66%; }
.wg__hole.is-up .wg__mole { transform: translateY(0); }
@keyframes wgMoleRise {
0% { transform: translateY(100%); }
8% { transform: translateY(0); }
20% { transform: translateY(0); }
30% { transform: translateY(100%); }
100% { transform: translateY(100%); }
}
실제 게임은 .is-up 을 붙였다 떼면 transition 이 그려 주고, 격자 미리보기만 같은 움직임을 @keyframes 로 다시 만드는데, 구멍마다 음수 지연을 달아 위상을 어긋나게 했기 때문에 애니메이션 18개가 한 바퀴에서 매끄럽게 이어집니다.
같은 구멍이 연달아 나오지 않게
function spawn() {
if (!playing) { return; }
sink();
var i = lastHole;
while (i === lastHole) { i = Math.floor(Math.random() * holes.length); } // 같은 구멍 연속 스폰은 피한다
lastHole = i; upIndex = i;
holes[i].classList.add('is-up');
downId = setTimeout(sink, UP_MS);
spawnId = setTimeout(spawn, 700 + Math.random() * 300);
}
한 번 솟으면 850ms 뒤 스스로 들어가고 다음 스폰은 700~1000ms 뒤라 화면에 두더지가 겹쳐 서는 일이 없으며, 누적면적 33.16%에 강도 평균 129.4 로 세 번째로 요란한 판이 됩니다.
04시선 피하기
화면의 눈이 커서를 계속 쫓아오고, 겹친 채로 버티면 벌점이 쌓이는, 포인터 이벤트를 가르치거나 커서 인터랙션 포트폴리오에 넣기 좋은 판입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="gde-eye-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></symbol>
<symbol id="gde-cursor-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M213.66 201L201 213.66a8 8 0 0 1-11.31 0l-51.31-51.31a8 8 0 0 0-13 2.46l-17.82 46.41a8 8 0 0 1-14.85-.71L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24a8 8 0 0 1 .71 14.85l-46.41 17.82a8 8 0 0 0-2.46 13l51.31 51.31a8 8 0 0 1 0 11.34" opacity=".2"/><path d="M168 132.69L214.08 115l.33-.13a16 16 0 0 0-1.41-29.8L52.92 32.8A15.95 15.95 0 0 0 32.8 52.92L85.07 213a15.82 15.82 0 0 0 14.41 11h.78a15.84 15.84 0 0 0 14.61-9.59l.13-.33L132.69 168L184 219.31a16 16 0 0 0 22.63 0l12.68-12.68a16 16 0 0 0 0-22.63ZM195.31 208L144 156.69a16 16 0 0 0-26 4.93c0 .11-.09.22-.13.32l-17.65 46L48 48l159.85 52.2l-45.95 17.64l-.32.13a16 16 0 0 0-4.93 26L208 195.31Z"/></g></symbol>
<symbol id="gde-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="gde-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="gde is-demo" id="gde">
<header class="gde__bar">
<p class="gde__title">
<svg class="gde__ico" aria-hidden="true"><use href="#gde-eye-ico"/></svg>
<span>시선 피하기</span>
</p>
<p class="gde__pill"><span class="gde__label">버틴 시간</span><b class="gde__num" id="scoreNum">0.0초</b></p>
<p class="gde__pill gde__pill--best">
<svg class="gde__ico gde__ico--sm" aria-hidden="true"><use href="#gde-trophy-ico"/></svg>
<b class="gde__num" id="bestNum">0.0초</b>
</p>
<button class="gde__btn" id="restartBtn" type="button" aria-label="다시 시작">
<svg class="gde__ico gde__ico--sm" aria-hidden="true"><use href="#gde-restart-ico"/></svg>
</button>
</header>
<div class="gde__arena" id="arena">
<p class="gde__dotsWrap"><span class="gde__dotsLabel">벌점</span><span class="gde__dots"><i class="gde__dot" id="dot0"></i><i class="gde__dot" id="dot1"></i><i class="gde__dot" id="dot2"></i></span></p>
<p class="gde__warn" id="warn">눈이 가깝다</p>
<div class="gde__eyeWrap" id="eyeWrap">
<span class="gde__ring" aria-hidden="true"></span>
<svg class="gde__eye" viewBox="0 0 120 120" aria-hidden="true">
<ellipse class="gde__ball" cx="60" cy="60" rx="54" ry="36"></ellipse>
<circle class="gde__iris" cx="60" cy="60" r="21"></circle>
<circle class="gde__pupil" cx="60" cy="60" r="10"></circle>
<circle class="gde__glint" cx="67" cy="53" r="5"></circle>
</svg>
</div>
<div class="gde__targetWrap" id="targetWrap">
<span class="gde__halo" aria-hidden="true"></span>
<svg class="gde__cursor" aria-hidden="true"><use href="#gde-cursor-ico"/></svg>
</div>
<div class="gde__over" id="over" hidden>
<div class="gde__overPanel">
<p class="gde__overTitle">눈에게 잡혔다</p>
<p class="gde__overLine" id="overLine">버틴 시간 0.0초</p>
<button class="gde__again" id="againBtn" type="button">
<svg class="gde__ico gde__ico--sm" aria-hidden="true"><use href="#gde-restart-ico"/></svg>
<span>다시 시작</span>
</button>
</div>
</div>
</div>
<p class="gde__hint">마우스를 움직여 눈을 피하세요 · 방향키로도 표적을 움직일 수 있어요 · 벌점이 3개가 되면 끝입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 시선 피하기 — 눈은 pointermove 로 옮겨진 표적을 rAF 보간(lerp)으로 쫓고, 움직임은 전부 translate3d 다.
// 표적과 눈 중심의 거리를 getBoundingClientRect 로 잰 뒤 임계 안에 들어오면 벌점이 오른다(3개면 패배).
// 격자 미리보기(.is-demo)는 눈과 표적이 각자의 경로를 도는 CSS 키프레임이다 — 손이 닿으면 뗀다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.gde {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 440px;
height: calc(100vh - clamp(16px, 8vw, 48px));
}
// ── 스코어보드 ──────────────────────────────────────────────
.gde__bar { display: flex; align-items: center; gap: $sp-1; flex-wrap: nowrap; }
.gde__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-ink;
}
.gde__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.gde__ico--sm { width: 12px; height: 12px; }
.gde__pill {
display: flex; align-items: center; gap: $sp-1; margin: 0;
border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($subject-ink, .08);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2; color: $subject-ink;
white-space: nowrap;
}
.gde__label { font-weight: 600; color: rgba($subject-ink, .72); }
.gde__num { font-weight: 800; }
.gde__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-ink, .08); cursor: pointer;
color: $subject-ink;
}
.gde__btn:hover { background: rgba($subject-ink, .16); }
.gde__btn:focus-visible { outline: 2px solid $color; outline-offset: 0; }
// ── 놀이판 ──────────────────────────────────────────────────
.gde__arena {
position: relative; flex: 1 1 auto; min-height: 0;
border-radius: $r-card; background: rgba($subject-ink, .05); overflow: hidden;
cursor: none;
}
.gde__eyeWrap, .gde__targetWrap { position: absolute; left: 0; top: 0; will-change: transform; }
.gde__eye { display: block; width: clamp(64px, 20vw, 96px); height: auto; }
.gde__ball { fill: $subject-cream; stroke: $subject-ink; stroke-width: 3; }
.gde__iris { fill: $color; }
.gde__pupil { fill: $subject-ink; }
.gde__glint { fill: $stage-paper; }
// 눈이 가까워지면 눈알 테두리가 오류색으로 변한다 — 움직임 없이 색만으로 경고를 준다
.gde.is-near .gde__ball { stroke: $error; }
// 위험 링 — 눈을 감싸는 테두리. 겹친 순간을 알린다
.gde__ring {
position: absolute; inset: -8px; border: 2px solid $error; border-radius: 50%;
opacity: 0; transform: scale(.9);
}
.gde.is-near .gde__ring { opacity: .9; }
.gde__cursor { display: block; width: 24px; height: 24px; color: $subject-ink;
filter: drop-shadow(0 1px 2px rgba($subject-ink, .25)); }
// 표적 둘레 링 — 커서(24px) 밖으로 10px 씩 번져 44px 이 된다. inset 은 간격이 아니라 위치 오프셋이다
.gde__halo {
position: absolute; inset: -10px;
border: 2px solid rgba($subject-ink, .35); border-radius: 50%; opacity: 0;
}
// ── 벌점 · 경고 ─────────────────────────────────────────────
.gde__dotsWrap { position: absolute; left: $sp-2; top: $sp-2; margin: 0; display: flex; align-items: center; gap: $sp-1; }
.gde__dotsLabel { font-weight: 700; font-size: 9px; line-height: 1; color: rgba($subject-ink, .72); }
.gde__dots { display: flex; gap: $sp-1; }
.gde__dot {
width: 10px; height: 10px; border-radius: 50%; background: rgba($subject-ink, .16);
transition: background-color $dur-quick $easing, transform $dur-quick $ease-pop;
}
.gde__dot.is-hit { background: $error; transform: scale(1.15); }
.gde__warn {
position: absolute; left: 50%; top: $sp-2; transform: translate(-50%, -4px); margin: 0;
border-radius: $r-pill; padding: $sp-1 $sp-2; background: $error; color: $stage-paper;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2;
opacity: 0; transition: opacity $dur-quick $easing, transform $dur-quick $easing;
pointer-events: none;
}
.gde.is-near .gde__warn { opacity: 1; transform: translate(-50%, 0); }
// ── 패배 화면 ───────────────────────────────────────────────
// display 를 직접 주는 규칙은 hidden 속성(UA display:none)을 이겨버린다 — 덮개가 사라지지 않는다
.gde [hidden] { display: none !important; }
.gde__over {
position: absolute; inset: 0; display: grid; place-items: center;
background: rgba($subject-ink, .55);
}
.gde__overPanel {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
border-radius: $r-card; padding: $sp-4; background: $stage-paper; box-shadow: $shadow-raised;
}
.gde__overTitle { margin: 0; font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.2; color: $subject-ink; }
.gde__overLine { margin: 0; font-weight: 600; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4; color: rgba($subject-ink, .72); }
.gde__again {
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;
}
.gde__again:hover { background: $subject-blue; }
.gde__again:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.gde__hint {
margin: 0; font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
color: rgba($subject-ink, .72);
}
// ── 격자 미리보기 — 눈과 표적이 무대를 배회한다 ──────────────
// 놀이판(432x196 기준) 안의 경로. 좁은 화면에서는 아래 미디어 블록이 좁은 경로로 갈아끼운다.
.gde.is-demo .gde__targetWrap {
animation-name: gdeTarget; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gdeTarget {
0% { transform: translate3d(338px, 138px, 0); }
22% { transform: translate3d(188px, 32px, 0); }
45% { transform: translate3d(48px, 128px, 0); }
68% { transform: translate3d(238px, 78px, 0); }
88% { transform: translate3d(373px, 48px, 0); }
100% { transform: translate3d(338px, 138px, 0); }
}
.gde.is-demo .gde__eyeWrap {
animation-name: gdeEye; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gdeEye {
0% { transform: translate3d(252px, 122px, 0); }
25% { transform: translate3d(207px, 72px, 0); }
50% { transform: translate3d(72px, 32px, 0); }
72% { transform: translate3d(47px, 102px, 0); }
100% { transform: translate3d(252px, 122px, 0); }
}
// 미리보기에서는 표적 주위 링이 느리게 번지고 경고 칩이 숨 쉰다
.gde.is-demo .gde__halo {
animation-name: gdeHalo; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gdeHalo {
0%, 100% { opacity: 0; transform: scale(.7); }
45% { opacity: .8; transform: scale(1.05); }
}
.gde.is-demo .gde__warn {
animation-name: gdeWarnBlink; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes gdeWarnBlink {
0%, 100% { opacity: 0; }
48% { opacity: 1; }
}
.gde.is-demo .gde__ring {
animation-name: gdeRingPulse; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes gdeRingPulse {
0%, 100% { opacity: 0; transform: scale(.9); }
50% { opacity: .85; transform: scale(1.04); }
}
@media (max-width: 340px) {
// 좁은 놀이판(294x121 기준) 경로 — 같은 동선을 축소해 옮긴다
.gde.is-demo .gde__targetWrap { animation-name: gdeTargetN; }
.gde.is-demo .gde__eyeWrap { animation-name: gdeEyeN; }
}
@keyframes gdeTargetN {
0% { transform: translate3d(226px, 81px, 0); }
22% { transform: translate3d(124px, 15px, 0); }
45% { transform: translate3d(29px, 75px, 0); }
68% { transform: translate3d(158px, 44px, 0); }
88% { transform: translate3d(248px, 25px, 0); }
100% { transform: translate3d(226px, 81px, 0); }
}
@keyframes gdeEyeN {
0% { transform: translate3d(172px, 73px, 0); }
25% { transform: translate3d(141px, 42px, 0); }
50% { transform: translate3d(49px, 17px, 0); }
72% { transform: translate3d(32px, 61px, 0); }
100% { transform: translate3d(172px, 73px, 0); }
}
// reduce 에서는 배회 연출을 끈다. 눈은 보간 없이 즉시 따라오고(JS 가 계수를 1로 올린다)
// 벌점·경고는 색과 칩으로만 보여 움직임을 대신한다.
@media (prefers-reduced-motion: reduce) {
.gde.is-demo .gde__targetWrap,
.gde.is-demo .gde__eyeWrap,
.gde.is-demo .gde__halo,
.gde.is-demo .gde__warn,
.gde.is-demo .gde__ring { animation: none !important; }
.gde__dot, .gde__warn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gde {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
height: calc(100vh - clamp(16px, 8vw, 48px));
}
.gde__bar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.gde__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: #17141a;
}
.gde__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.gde__ico--sm {
width: 12px;
height: 12px;
}
.gde__pill {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.08);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
color: #17141a;
white-space: nowrap;
}
.gde__label {
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.gde__num {
font-weight: 800;
}
.gde__btn {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 0;
border-radius: 4px;
padding: 0;
background: rgba(23, 20, 26, 0.08);
cursor: pointer;
color: #17141a;
}
.gde__btn:hover {
background: rgba(23, 20, 26, 0.16);
}
.gde__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 0;
}
.gde__arena {
position: relative;
flex: 1 1 auto;
min-height: 0;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
overflow: hidden;
cursor: none;
}
.gde__eyeWrap, .gde__targetWrap {
position: absolute;
left: 0;
top: 0;
will-change: transform;
}
.gde__eye {
display: block;
width: clamp(64px, 20vw, 96px);
height: auto;
}
.gde__ball {
fill: #fff7e6;
stroke: #17141a;
stroke-width: 3;
}
.gde__iris {
fill: #2f6df6;
}
.gde__pupil {
fill: #17141a;
}
.gde__glint {
fill: #ffffff;
}
.gde.is-near .gde__ball {
stroke: #c2361a;
}
.gde__ring {
position: absolute;
inset: -8px;
border: 2px solid #c2361a;
border-radius: 50%;
opacity: 0;
transform: scale(0.9);
}
.gde.is-near .gde__ring {
opacity: 0.9;
}
.gde__cursor {
display: block;
width: 24px;
height: 24px;
color: #17141a;
filter: drop-shadow(0 1px 2px rgba(23, 20, 26, 0.25));
}
.gde__halo {
position: absolute;
inset: -10px;
border: 2px solid rgba(23, 20, 26, 0.35);
border-radius: 50%;
opacity: 0;
}
.gde__dotsWrap {
position: absolute;
left: 8px;
top: 8px;
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.gde__dotsLabel {
font-weight: 700;
font-size: 9px;
line-height: 1;
color: rgba(23, 20, 26, 0.72);
}
.gde__dots {
display: flex;
gap: 4px;
}
.gde__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.16);
transition: background-color 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.gde__dot.is-hit {
background: #c2361a;
transform: scale(1.15);
}
.gde__warn {
position: absolute;
left: 50%;
top: 8px;
transform: translate(-50%, -4px);
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: #c2361a;
color: #ffffff;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
opacity: 0;
transition: opacity 220ms ease-out, transform 220ms ease-out;
pointer-events: none;
}
.gde.is-near .gde__warn {
opacity: 1;
transform: translate(-50%, 0);
}
.gde [hidden] {
display: none !important;
}
.gde__over {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.55);
}
.gde__overPanel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: 16px;
padding: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gde__overTitle {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.2;
color: #17141a;
}
.gde__overLine {
margin: 0;
font-weight: 600;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.gde__again {
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;
}
.gde__again:hover {
background: #2f6df6;
}
.gde__again:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.gde__hint {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.gde.is-demo .gde__targetWrap {
animation-name: gdeTarget;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes gdeTarget {
0% {
transform: translate3d(338px, 138px, 0);
}
22% {
transform: translate3d(188px, 32px, 0);
}
45% {
transform: translate3d(48px, 128px, 0);
}
68% {
transform: translate3d(238px, 78px, 0);
}
88% {
transform: translate3d(373px, 48px, 0);
}
100% {
transform: translate3d(338px, 138px, 0);
}
}
.gde.is-demo .gde__eyeWrap {
animation-name: gdeEye;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes gdeEye {
0% {
transform: translate3d(252px, 122px, 0);
}
25% {
transform: translate3d(207px, 72px, 0);
}
50% {
transform: translate3d(72px, 32px, 0);
}
72% {
transform: translate3d(47px, 102px, 0);
}
100% {
transform: translate3d(252px, 122px, 0);
}
}
.gde.is-demo .gde__halo {
animation-name: gdeHalo;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes gdeHalo {
0%, 100% {
opacity: 0;
transform: scale(0.7);
}
45% {
opacity: 0.8;
transform: scale(1.05);
}
}
.gde.is-demo .gde__warn {
animation-name: gdeWarnBlink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes gdeWarnBlink {
0%, 100% {
opacity: 0;
}
48% {
opacity: 1;
}
}
.gde.is-demo .gde__ring {
animation-name: gdeRingPulse;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes gdeRingPulse {
0%, 100% {
opacity: 0;
transform: scale(0.9);
}
50% {
opacity: 0.85;
transform: scale(1.04);
}
}
@media (max-width: 340px) {
.gde.is-demo .gde__targetWrap {
animation-name: gdeTargetN;
}
.gde.is-demo .gde__eyeWrap {
animation-name: gdeEyeN;
}
}
@keyframes gdeTargetN {
0% {
transform: translate3d(226px, 81px, 0);
}
22% {
transform: translate3d(124px, 15px, 0);
}
45% {
transform: translate3d(29px, 75px, 0);
}
68% {
transform: translate3d(158px, 44px, 0);
}
88% {
transform: translate3d(248px, 25px, 0);
}
100% {
transform: translate3d(226px, 81px, 0);
}
}
@keyframes gdeEyeN {
0% {
transform: translate3d(172px, 73px, 0);
}
25% {
transform: translate3d(141px, 42px, 0);
}
50% {
transform: translate3d(49px, 17px, 0);
}
72% {
transform: translate3d(32px, 61px, 0);
}
100% {
transform: translate3d(172px, 73px, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.gde.is-demo .gde__targetWrap,
.gde.is-demo .gde__eyeWrap,
.gde.is-demo .gde__halo,
.gde.is-demo .gde__warn,
.gde.is-demo .gde__ring {
animation: none !important;
}
.gde__dot, .gde__warn {
transition: none !important;
}
}var BEST_KEY = 'p301-04-best';
var TIME_FMT = '{s}초';
var CAUGHT_LINE = '버틴 시간 {s}초';
var WARN_TEXT = '눈이 가깝다';
// 눈이 표적을 쫓는 보간 계수 — 프레임당 0.055 로 부드럽게 따라온다 (너무 빠르면 피할 수 없다)
// reduce 에서는 보간을 1로 올려 눈이 즉시 따라오게 한다 (transition 을 쓰지 않는 이유와 같다)
var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LERP = REDUCE ? 1 : 0.055;
// 눈 중심과 표적 중심이 이 거리(px) 안이면 위험 — 벌점이 쌓인다
var DANGER_DIST = 60;
// 위험 상태가 이 시간(ms) 만큼 이어지면 벌점 1개
var DANGER_MS = 450;
var KEY_STEP = 26;
var root = document.getElementById('gde');
var arena = document.getElementById('arena');
var eyeWrap = document.getElementById('eyeWrap');
var targetWrap = document.getElementById('targetWrap');
var scoreNum = document.getElementById('scoreNum');
var bestNum = document.getElementById('bestNum');
var warn = document.getElementById('warn');
var over = document.getElementById('over');
var overLine = document.getElementById('overLine');
var dots = [document.getElementById('dot0'), document.getElementById('dot1'), document.getElementById('dot2')];
var running = false;
var dead = false;
var startT = 0;
var lastNow = 0;
var penalty = 0;
var dangerAcc = 0;
var eyePos = { x: 0, y: 0 };
var targetPos = { x: 0, y: 0 };
var best = 0;
function fmtTime(sec) { return TIME_FMT.replace('{s}', sec.toFixed(1)); }
function loadBest() {
try { best = Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { best = 0; }
bestNum.textContent = fmtTime(best);
}
function saveBest(sec) {
if (sec <= best) { return; }
best = sec;
try { window.localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
bestNum.textContent = fmtTime(best);
}
function arenaRect() { return arena.getBoundingClientRect(); }
function placeTarget(x, y) {
var r = arenaRect();
targetPos.x = Math.max(8, Math.min(r.width - 8, x));
targetPos.y = Math.max(8, Math.min(r.height - 8, y));
}
function centerOf(el) {
var r = el.getBoundingClientRect();
var a = arenaRect();
return { x: r.left - a.left + r.width / 2, y: r.top - a.top + r.height / 2 };
}
// 눈과 표적은 translate3d 로만 움직인다 — left/top 을 바꾸면 매 프레임 배치이 다시 계산된다
function paint() {
eyeWrap.style.transform = 'translate3d(' + (eyePos.x - eyeWrap.offsetWidth / 2) + 'px,' + (eyePos.y - eyeWrap.offsetHeight / 2) + 'px,0)';
targetWrap.style.transform = 'translate3d(' + (targetPos.x - targetWrap.offsetWidth / 2) + 'px,' + (targetPos.y - targetWrap.offsetHeight / 2) + 'px,0)';
}
function resetGame(keepInput) {
running = false;
dead = false;
penalty = 0;
dangerAcc = 0;
for (var i = 0; i < dots.length; i++) { dots[i].className = 'gde__dot'; }
root.classList.remove('is-danger');
over.hidden = true;
scoreNum.textContent = fmtTime(0);
var r = arenaRect();
eyePos = { x: r.width / 2, y: r.height / 2 };
if (!keepInput) { placeTarget(r.width * 0.74, r.height * 0.5); }
paint();
}
function begin() {
if (running || dead) { return; }
root.classList.remove('is-demo');
// 미리보기 키프레임이 그려 놓은 자리에서 시작한다 — 중앙으로 튀지 않게 현재 위치를 읽어 온다
eyePos = centerOf(eyeWrap);
penalty = 0;
dangerAcc = 0;
over.hidden = true;
for (var i = 0; i < dots.length; i++) { dots[i].className = 'gde__dot'; }
running = true;
startT = performance.now();
lastNow = startT;
}
function addPenalty() {
penalty += 1;
if (dots[penalty - 1]) { dots[penalty - 1].className = 'gde__dot is-hit'; }
if (penalty >= 3) { endGame(); }
}
function endGame() {
running = false;
dead = true;
var sec = (performance.now() - startT) / 1000;
scoreNum.textContent = fmtTime(sec);
overLine.textContent = CAUGHT_LINE.replace('{s}', sec.toFixed(1));
over.hidden = false;
saveBest(sec);
}
function tick(now) {
var dt = now - lastNow;
lastNow = now;
var demo = root.classList.contains('is-demo');
if (!demo) {
// lerp 추적 — 눈은 표적을 향해 매 프레임 조금씩 다가온다
eyePos.x += (targetPos.x - eyePos.x) * LERP;
eyePos.y += (targetPos.y - eyePos.y) * LERP;
paint();
var ex = eyePos.x, ey = eyePos.y;
var near = Math.hypot(targetPos.x - ex, targetPos.y - ey) < DANGER_DIST;
root.classList.toggle('is-near', near);
if (running) {
var sec = (now - startT) / 1000;
scoreNum.textContent = fmtTime(sec);
if (near) {
dangerAcc += dt;
if (dangerAcc >= DANGER_MS) { dangerAcc = 0; addPenalty(); }
} else {
dangerAcc = Math.max(0, dangerAcc - dt * 2);
}
}
}
window.requestAnimationFrame(tick);
}
arena.addEventListener('pointermove', function (e) {
var r = arenaRect();
placeTarget(e.clientX - r.left, e.clientY - r.top);
begin();
});
window.addEventListener('keydown', function (e) {
var k = e.key;
var dx = 0, dy = 0;
if (k === 'ArrowLeft') { dx = -KEY_STEP; }
else if (k === 'ArrowRight') { dx = KEY_STEP; }
else if (k === 'ArrowUp') { dy = -KEY_STEP; }
else if (k === 'ArrowDown') { dy = KEY_STEP; }
else { return; }
e.preventDefault();
if (!running) { begin(); }
placeTarget(targetPos.x + dx, targetPos.y + dy);
});
document.getElementById('restartBtn').addEventListener('click', function () { root.classList.remove('is-demo'); resetGame(false); });
document.getElementById('againBtn').addEventListener('click', function () { resetGame(false); });
warn.textContent = WARN_TEXT;
loadBest();
resetGame(false);
window.requestAnimationFrame(tick);// GazeDodgeEye — vanilla 판(run/301/demo/04-gaze-dodge-eye) 의 React 포트.
// 눈은 pointermove 로 옮겨진 표적을 rAF 보간(lerp)으로 쫓고 움직임은 전부 translate3d 다.
// 게임 상태는 60fps 루프 안에서만 바뀌므로 useRef 하나에 묶고, 화면 전환(미리보기·벌점·패배)만 state 로 렌더한다.
// duration·easing·accentColor 는 props → CSS 변수로 들어온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GazeDodgeEye.module.scss";
export type GazeDodgeEyeProps = {
duration?: string;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type GameState = {
running: boolean; dead: boolean; startT: number; lastNow: number;
penalty: number; dangerAcc: number; best: number; near: boolean;
eye: { x: number; y: number }; target: { x: number; y: number };
};
const BEST_KEY = "p301-04-best";
const TIME_FMT = "{s}초";
const CAUGHT_LINE = "버틴 시간 {s}초";
const WARN_TEXT = "눈이 가깝다";
const HINT_TEXT = "마우스를 움직여 눈을 피하세요 · 방향키로도 표적을 움직일 수 있어요 · 벌점이 3개가 되면 끝입니다";
const AGAIN_LABEL = "다시 시작";
const TITLE_TEXT = "시선 피하기";
const SURVIVED_LABEL = "버틴 시간";
const PENALTY_LABEL = "벌점";
const CAUGHT_TITLE = "눈에게 잡혔다";
// 눈 추적 보간 계수 — 프레임당 0.055. reduce 에서는 1로 올려 즉시 따라오게 한다
const LERP = 0.055;
const DANGER_DIST = 60;
const DANGER_MS = 450;
const KEY_STEP = 26;
function fmtTime(sec: number): string {
return TIME_FMT.replace("{s}", sec.toFixed(1));
}
export default function GazeDodgeEye({ duration, easing, accentColor }: GazeDodgeEyeProps) {
const [isDemo, setIsDemo] = useState(true);
const [pen, setPen] = useState(0);
const [overOpen, setOverOpen] = useState(false);
const [overLine, setOverLine] = useState(CAUGHT_LINE.replace("{s}", "0.0"));
const [bestText, setBestText] = useState(fmtTime(0));
const [near, setNear] = useState(false);
const arenaRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const eyeRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const targetRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const scoreRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
// rAF 루프 안에서 isDemo state 를 읽기 위한 거울과, 바깥 재시작 버튼이 부를 초기화 구멍
const isDemoRef = useRef(true) as Box<boolean>;
const api = useRef<{ reset(): void }>({ reset() { /* effect 가 들어오기 전의 호출은 무시한다 */ } }) as Box<{ reset(): void }>;
const g = useRef({
running: false, dead: false, startT: 0, lastNow: 0,
penalty: 0, dangerAcc: 0, best: 0, near: false,
eye: { x: 0, y: 0 }, target: { x: 0, y: 0 },
}) as Box<GameState>;
useEffect(() => { isDemoRef.current = isDemo; }, [isDemo]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lerp = reduce ? 1 : LERP;
const st = g.current;
try { st.best = Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { st.best = 0; }
setBestText(fmtTime(st.best));
const arena = arenaRef.current;
const eyeWrap = eyeRef.current;
const targetWrap = targetRef.current;
if (!arena || !eyeWrap || !targetWrap) { return; }
const arenaRect = () => arena.getBoundingClientRect();
const placeTarget = (x: number, y: number) => {
const r = arenaRect();
st.target.x = Math.max(8, Math.min(r.width - 8, x));
st.target.y = Math.max(8, Math.min(r.height - 8, y));
};
const centerOf = (el: HTMLElement) => {
const r = el.getBoundingClientRect();
const a = arenaRect();
return { x: r.left - a.left + r.width / 2, y: r.top - a.top + r.height / 2 };
};
// 눈과 표적은 translate3d 로만 움직인다 — left/top 을 바꾸면 매 프레임 배치가 다시 계산된다
const paint = () => {
eyeWrap.style.transform =
"translate3d(" + (st.eye.x - eyeWrap.offsetWidth / 2) + "px," + (st.eye.y - eyeWrap.offsetHeight / 2) + "px,0)";
targetWrap.style.transform =
"translate3d(" + (st.target.x - targetWrap.offsetWidth / 2) + "px," + (st.target.y - targetWrap.offsetHeight / 2) + "px,0)";
};
const saveBest = (sec: number) => {
if (sec <= st.best) { return; }
st.best = sec;
try { window.localStorage.setItem(BEST_KEY, String(st.best)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
setBestText(fmtTime(st.best));
};
const endGame = () => {
st.running = false;
st.dead = true;
const sec = (performance.now() - st.startT) / 1000;
if (scoreRef.current) { scoreRef.current.textContent = fmtTime(sec); }
setOverLine(CAUGHT_LINE.replace("{s}", sec.toFixed(1)));
setOverOpen(true);
saveBest(sec);
};
const addPenalty = () => {
st.penalty += 1;
setPen(st.penalty);
if (st.penalty >= 3) { endGame(); }
};
const begin = () => {
if (st.running || st.dead) { return; }
setIsDemo(false);
// 미리보기 키프레임이 그려 놓은 자리에서 시작한다 — 중앙으로 튀지 않게 현재 위치를 읽어 온다
st.eye = centerOf(eyeWrap);
st.penalty = 0;
st.dangerAcc = 0;
setPen(0);
setOverOpen(false);
st.running = true;
st.startT = performance.now();
st.lastNow = st.startT;
};
const resetGame = () => {
st.running = false;
st.dead = false;
st.penalty = 0;
st.dangerAcc = 0;
setPen(0);
setOverOpen(false);
if (scoreRef.current) { scoreRef.current.textContent = fmtTime(0); }
const r = arenaRect();
st.eye = { x: r.width / 2, y: r.height / 2 };
placeTarget(r.width * 0.74, r.height * 0.5);
paint();
};
api.current.reset = resetGame;
let raf = 0;
const tick = (now: number) => {
const dt = now - st.lastNow;
st.lastNow = now;
if (!isDemoRef.current) {
st.eye.x += (st.target.x - st.eye.x) * lerp;
st.eye.y += (st.target.y - st.eye.y) * lerp;
paint();
const isNear = Math.hypot(st.target.x - st.eye.x, st.target.y - st.eye.y) < DANGER_DIST;
if (isNear !== st.near) { st.near = isNear; setNear(isNear); }
if (st.running) {
const sec = (now - st.startT) / 1000;
if (scoreRef.current) { scoreRef.current.textContent = fmtTime(sec); }
if (isNear) {
st.dangerAcc += dt;
if (st.dangerAcc >= DANGER_MS) { st.dangerAcc = 0; addPenalty(); }
} else {
st.dangerAcc = Math.max(0, st.dangerAcc - dt * 2);
}
}
}
raf = window.requestAnimationFrame(tick);
};
const onMove = (e: PointerEvt) => {
const r = arenaRect();
placeTarget(e.clientX - r.left, e.clientY - r.top);
begin();
};
const onKey = (e: KeyEvt) => {
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") { dx = -KEY_STEP; }
else if (e.key === "ArrowRight") { dx = KEY_STEP; }
else if (e.key === "ArrowUp") { dy = -KEY_STEP; }
else if (e.key === "ArrowDown") { dy = KEY_STEP; }
else { return; }
e.preventDefault();
begin();
placeTarget(st.target.x + dx, st.target.y + dy);
};
resetGame();
raf = window.requestAnimationFrame(tick);
arena.addEventListener("pointermove", onMove);
window.addEventListener("keydown", onKey);
return () => {
window.cancelAnimationFrame(raf);
arena.removeEventListener("pointermove", onMove);
window.removeEventListener("keydown", onKey);
};
}, []);
const vars: Vars = {};
if (duration) { vars["--duration"] = duration; }
if (easing) { vars["--easing"] = easing; }
if (accentColor) { vars["--color"] = accentColor; }
const rootCls = [styles.root, isDemo ? styles.isDemo : "", near ? styles.isNear : ""].filter(Boolean).join(" ");
return (
<div className={rootCls} 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="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" />
<path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</svg>
<span>{TITLE_TEXT}</span>
</p>
<p className={styles.pill}>
<span className={styles.label}>{SURVIVED_LABEL}</span>
<b className={styles.num} ref={scoreRef}>{fmtTime(0)}</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}>{bestText}</b>
</p>
<button
className={styles.btn}
type="button"
aria-label={AGAIN_LABEL}
onClick={() => { setIsDemo(false); api.current.reset(); }}
>
<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.arena} ref={arenaRef}>
<p className={styles.dotsWrap}>
<span className={styles.dotsLabel}>{PENALTY_LABEL}</span>
<span className={styles.dots}>
<i className={pen > 0 ? styles.dotHit : styles.dot} />
<i className={pen > 1 ? styles.dotHit : styles.dot} />
<i className={pen > 2 ? styles.dotHit : styles.dot} />
</span>
</p>
<p className={styles.warn}>{WARN_TEXT}</p>
<div className={styles.eyeWrap} ref={eyeRef}>
<span className={styles.ring} aria-hidden="true" />
<svg className={styles.eye} viewBox="0 0 120 120" aria-hidden="true">
<ellipse className={styles.ball} cx="60" cy="60" rx="54" ry="36" />
<circle className={styles.iris} cx="60" cy="60" r="21" />
<circle className={styles.pupil} cx="60" cy="60" r="10" />
<circle className={styles.glint} cx="67" cy="53" r="5" />
</svg>
</div>
<div className={styles.targetWrap} ref={targetRef}>
<span className={styles.halo} aria-hidden="true" />
<svg className={styles.cursor} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M213.66 201L201 213.66a8 8 0 0 1-11.31 0l-51.31-51.31a8 8 0 0 0-13 2.46l-17.82 46.41a8 8 0 0 1-14.85-.71L40.41 50.44a8 8 0 0 1 10-10l160.1 52.24a8 8 0 0 1 .71 14.85l-46.41 17.82a8 8 0 0 0-2.46 13l51.31 51.31a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="M168 132.69L214.08 115l.33-.13a16 16 0 0 0-1.41-29.8L52.92 32.8A15.95 15.95 0 0 0 32.8 52.92L85.07 213a15.82 15.82 0 0 0 14.41 11h.78a15.84 15.84 0 0 0 14.61-9.59l.13-.33L132.69 168L184 219.31a16 16 0 0 0 22.63 0l12.68-12.68a16 16 0 0 0 0-22.63ZM195.31 208L144 156.69a16 16 0 0 0-26 4.93c0 .11-.09.22-.13.32l-17.65 46L48 48l159.85 52.2l-45.95 17.64l-.32.13a16 16 0 0 0-4.93 26L208 195.31Z" />
</svg>
</div>
<div className={styles.over} hidden={!overOpen}>
<div className={styles.overPanel}>
<p className={styles.overTitle}>{CAUGHT_TITLE}</p>
<p className={styles.overLine}>{overLine}</p>
<button
className={styles.again}
type="button"
onClick={() => { api.current.reset(); }}
>
<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>
</div>
<p className={styles.hint}>{HINT_TEXT}</p>
</div>
);
}04 · 시선 피하기 — 측정 누적면적 27.768% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
좌표는 놀이판 왼쪽 위를 0으로
function arenaRect() { return arena.getBoundingClientRect(); }
function placeTarget(x, y) {
var r = arenaRect();
targetPos.x = Math.max(8, Math.min(r.width - 8, x));
targetPos.y = Math.max(8, Math.min(r.height - 8, y));
}
function centerOf(el) {
var r = el.getBoundingClientRect();
var a = arenaRect();
return { x: r.left - a.left + r.width / 2, y: r.top - a.top + r.height / 2 };
}
function paint() {
eyeWrap.style.transform = 'translate3d(' + (eyePos.x - eyeWrap.offsetWidth / 2) + 'px,' + (eyePos.y - eyeWrap.offsetHeight / 2) + 'px,0)';
targetWrap.style.transform = 'translate3d(' + (targetPos.x - targetWrap.offsetWidth / 2) + 'px,' + (targetPos.y - targetWrap.offsetHeight / 2) + 'px,0)';
}
getBoundingClientRect 로 창 기준 좌표를 놀이판 기준으로 한 번 옮겨 두면 그 뒤로는 translate3d 두 줄이 전부라 left·top 을 건드릴 일이 없습니다.
매 프레임 5.5%씩 다가오는 눈
function tick(now) {
var dt = now - lastNow;
lastNow = now;
var demo = root.classList.contains('is-demo');
if (!demo) {
// lerp 추적 — 눈은 표적을 향해 매 프레임 조금씩 다가온다
eyePos.x += (targetPos.x - eyePos.x) * LERP;
eyePos.y += (targetPos.y - eyePos.y) * LERP;
paint();
var ex = eyePos.x, ey = eyePos.y;
var near = Math.hypot(targetPos.x - ex, targetPos.y - ey) < DANGER_DIST;
root.classList.toggle('is-near', near);
if (running) {
var sec = (now - startT) / 1000;
scoreNum.textContent = fmtTime(sec);
if (near) {
dangerAcc += dt;
if (dangerAcc >= DANGER_MS) { dangerAcc = 0; addPenalty(); }
} else {
dangerAcc = Math.max(0, dangerAcc - dt * 2);
}
}
}
window.requestAnimationFrame(tick);
}
남은 거리의 LERP 배만큼만 좁히니 눈은 절대 따라잡지 못하면서도 계속 가까워지고, 60px 안에 450ms 동안 머물러야 벌점 하나가 붙고, 벗어나면 두 배 속도로 게이지가 빠집니다.
05리듬 탭
세 줄로 떨어지는 노트가 판정선을 지나는 순간 J·K·L 을 눌러 맞히는, 키보드 연습이나 대기 화면의 손가락 놀이로 쓰는 판입니다.

<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 포함 · 비밀번호 보기
판정 기준은 애니메이션 자신의 시계
// 곡 시작부터 흐른 시각을 그 노트의 낙하 시간으로 환산한 벽시계 값
function wallNow(note) {
return (performance.now() - st.t0) - Number(note.getAttribute('data-due')) + st.fallMs;
}
// 판정에 쓰는 시각 — 노트가 실제로 그려진 위치가 곧 판정 기준이므로 낙하 애니메이션의
// 시계(currentTime 에서 delay 를 뺀 값 = 낙하가 시작된 뒤 흐른 시간)를 읽는다.
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);
}
벽시계로 채점하면 탭이 느려진 브라우저에서 눈에 보이는 위치와 판정이 어긋나므로, getAnimations 로 잡은 낙하 애니메이션의 localTime 을 기준으로 삼아 그려진 자리가 곧 점수가 되게 했습니다.
맞힌 노트는 그 자리에 멈춘다
.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;
}
@keyframes rtlFall {
from { transform: translateY(-16px); }
to { transform: translateY(var(--fall-h)); }
}
MDN animation-play-state 문서대로 paused 는 진행 상태를 버리지 않고 그대로 얼리므로 노트는 맞은 높이에 박히고, 판정 창은 115ms 안이면 퍼펙트, 240ms 안이면 좋음으로 갈립니다.
06포커스 미로 탈출
방향키로 칸을 옮겨 다니며 출구를 찾는 미로인데 벽은 애초에 포커스를 못 받아 막히는, 키보드 접근성을 보여 주거나 탭 이동을 가르치는 온보딩에 쓰는 판입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="fme-pad-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2"/><path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/></g></symbol>
<symbol id="fme-flag-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2"/><path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87"/></g></symbol>
<symbol id="fme-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="fme-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>
<symbol id="fme-char-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M176 48a24 24 0 1 1-24-24a24 24 0 0 1 24 24" opacity=".2"/><path d="M152 80a32 32 0 1 0-32-32a32 32 0 0 0 32 32m0-48a16 16 0 1 1-16 16a16 16 0 0 1 16-16m64 112a8 8 0 0 1-8 8c-35.31 0-52.95-17.81-67.12-32.12c-2.74-2.77-5.36-5.4-8-7.84l-13.43 30.88l37.2 26.57A8 8 0 0 1 160 176v56a8 8 0 0 1-16 0v-51.88l-31.07-22.2l-33.59 77.27A8 8 0 0 1 72 240a7.8 7.8 0 0 1-3.19-.67a8 8 0 0 1-4.15-10.52l54.08-124.37c-9.31-1.65-20.92 1.2-34.7 8.58a164 164 0 0 0-30.57 21.77a8 8 0 0 1-10.95-11.66c2.5-2.35 61.69-57.22 98.72-25.08c3.83 3.32 7.48 7 11 10.58C166.19 122.7 179.36 136 208 136a8 8 0 0 1 8 8"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="fme is-demo" id="fme">
<header class="fme__bar">
<p class="fme__title">
<svg class="fme__ico" aria-hidden="true"><use href="#fme-pad-ico"/></svg>
<span>포커스 미로 탈출</span>
</p>
<p class="fme__pill"><span class="fme__label">걸음</span><b class="fme__num" id="stepNum">0</b></p>
<p class="fme__pill fme__pill--best">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-trophy-ico"/></svg>
<b class="fme__num" id="bestNum">-</b>
</p>
<button class="fme__btn" id="restartBtn" type="button" aria-label="다시 시작">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-restart-ico"/></svg>
</button>
</header>
<div class="fme__mazeWrap">
<div class="fme__maze" id="maze" role="group" aria-label="7×5 미로. 방향키로 움직이세요"></div>
<div class="fme__done" id="done" hidden>
<div class="fme__donePanel">
<p class="fme__doneTitle">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-flag-ico"/></svg>
<span>탈출!</span>
</p>
<p class="fme__doneLine" id="doneLine">0 걸음</p>
<button class="fme__again" id="againBtn" type="button">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-restart-ico"/></svg>
<span>다시 시작</span>
</button>
</div>
</div>
</div>
<p class="fme__hint">방향키로 칸을 옮겨 깃발까지 가세요 · 벽은 포커스를 받지 않아 저절로 막힙니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포커스 미로 탈출 — 7×5 칸을 grid-template-areas 로 배치한다. 통로는 button, 벽은 div 라
// 벽이 포커스를 못 받는 것이 곧 "막힌다" 는 규칙이다(막는 코드를 따로 쓰지 않는다).
// keydown 이 인접 통로 칸으로 focus({preventScroll:true}) 를 옮기고, 캐릭터는 :focus 인 칸에 나타난다.
// 미리보기(.is-demo)는 타이머가 정답 경로로 포커스를 옮겨 캐릭터가 미로를 걷는 연출이다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.fme {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 440px;
}
// ── 스코어보드 ──────────────────────────────────────────────
.fme__bar { display: flex; align-items: center; gap: $sp-1; flex-wrap: nowrap; }
.fme__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-ink;
}
.fme__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.fme__ico--sm { width: 12px; height: 12px; }
.fme__pill {
display: flex; align-items: center; gap: $sp-1; margin: 0;
border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($subject-ink, .14);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2; color: $subject-ink;
white-space: nowrap;
}
.fme__label { font-weight: 600; color: rgba($subject-ink, .78); }
.fme__num { font-weight: 800; }
.fme__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-ink, .14); cursor: pointer;
color: $subject-ink;
}
.fme__btn:hover { background: rgba($subject-ink, .24); }
.fme__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 0; }
// ── 미로 ────────────────────────────────────────────────────
.fme__mazeWrap { position: relative; }
// 7열 5행 — 칸 이름은 index.html 의 MAZE 표와 같은 좌표계다(c<열>r<행>)
// 칸은 고정 폭 격자다 — 1fr 로 늘리면 5행 높이가 무대(300px)를 넘는다
.fme__maze {
display: grid; gap: $sp-1; justify-content: center;
grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
grid-template-areas:
"c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0"
"c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1"
"c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2"
"c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3"
"c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
border-radius: $r-card; padding: $sp-1; background: rgba($subject-ink, .12);
justify-self: center;
}
.fme__cell, .fme__wall {
aspect-ratio: 1 / 1; min-width: 0; border-radius: $r-xs;
}
.fme__wall { background: $subject-ink; }
.fme__cell {
display: grid; place-items: center; width: 100%;
border: 0; padding: 0; background: $subject-cream; cursor: pointer; color: $color;
}
.fme__cell.is-start { background: rgba($subject-cream, .6); }
// 출구는 깃발 자리 — 잉크 글자를 얹지 않으니 파란 면을 그대로 쓴다
.fme__cell.is-goal { background: $color; }
// 키보드로 짚은 칸만 테두리가 선다 — 마우스 클릭으로는 뜨지 않는다
.fme__cell:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
// 캐릭터 — 포커스를 받은 칸에서만 보인다. 벽은 button 이 아니라 포커스를 못 받으므로 갈 수 없다
.fme__char { width: 68%; height: 68%; opacity: 0; transform: scale(.6); color: $color; }
.fme__cell:focus .fme__char {
opacity: 1; transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.fme__cell.is-goal:focus .fme__char { color: $subject-cream; }
// 발자국 자국 — 포커스 칸 뒤로 은은한 원이 남아 지금 어디에 서 있는지 한눈에 보인다
.fme__cell::after {
content: ""; position: absolute; width: 100%; height: 100%; border-radius: $r-xs;
box-shadow: inset 0 0 0 2px rgba($color, .5); opacity: 0;
}
.fme__cell { position: relative; }
.fme__cell:focus::after { opacity: 1; }
// ── 완료 덮개 ───────────────────────────────────────────────
// display 를 직접 주는 규칙은 hidden 속성(UA display:none)을 이겨버린다 — 덮개가 사라지지 않는다
.fme [hidden] { display: none !important; }
.fme__done { position: absolute; inset: 0; display: grid; place-items: center; background: rgba($subject-ink, .55); }
.fme__donePanel {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
border-radius: $r-card; padding: $sp-4; background: $stage-paper; box-shadow: $shadow-raised;
}
.fme__doneTitle {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.2; color: $subject-ink;
}
.fme__doneLine { margin: 0; font-weight: 600; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4; color: rgba($subject-ink, .78); }
.fme__again {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-control; padding: $sp-1 $sp-3; background: $color;
color: $subject-cream; font-family: inherit; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4; cursor: pointer;
}
.fme__again:hover { background: $subject-blue; }
.fme__again:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.fme__hint {
margin: 0; font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
color: rgba($subject-ink, .78);
}
// ── 격자 미리보기 ───────────────────────────────────────────
// 걷는 연출은 JS 타이머가 포커스를 옮겨 만든다(스크롤 없이 — preventScroll).
// CSS 는 그 위에 걸음 박자를 얹는다: 포커스 칸의 캐릭터가 발을 구른다.
.fme.is-demo .fme__cell:focus .fme__char {
animation-name: fmeStep; animation-duration: $dur-base; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fmeStep {
0%, 100% { transform: scale(1) translateY(0); }
40% { transform: scale(1.08) translateY(-2px); }
}
// 정답 길이 출발에서 출구까지 차례로 밝아진다 — 걷는 캐릭터가 어디로 가는지가 한눈에 읽힌다.
// 지연이 걸린 루프라 fill-mode: backwards 가 필수다 (없으면 첫 프레임에 전부 켜진 상태로 번쩍한다).
.fme.is-demo .fme__cell.is-path {
animation-name: fmePath; animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes fmePath {
0% { background-color: $subject-cream; }
10% { background-color: rgba($color, .34); }
55% { background-color: rgba($color, .34); }
75% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
// 출구 깃발 칸은 미리보기 동안 숨을 쉬어 "여기가 목적지" 를 알린다
.fme.is-demo .fme__cell.is-goal {
animation-name: fmeGoal; animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fmeGoal {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .82; transform: scale(1.06); }
}
// 폰 폭에서는 칸을 16px 로 줄여 5행 + 스코어보드 + 안내가 200px 안에 들어오게 한다
@media (max-width: 340px) {
.fme { gap: $sp-1; }
.fme__maze { grid-template-columns: repeat(7, 16px); }
.fme__donePanel { padding: $sp-2; }
}
// reduce — 걸음 박자와 깃발 숨쉬기를 끄고 캐릭터를 상시(전환 없이) 보이게 둔다
@media (prefers-reduced-motion: reduce) {
.fme.is-demo .fme__cell:focus .fme__char,
.fme.is-demo .fme__cell.is-path,
.fme.is-demo .fme__cell.is-goal { animation: none !important; }
.fme__cell:focus .fme__char { transition: none !important; opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fme {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.fme__bar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.fme__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: #17141a;
}
.fme__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.fme__ico--sm {
width: 12px;
height: 12px;
}
.fme__pill {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.14);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
color: #17141a;
white-space: nowrap;
}
.fme__label {
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.fme__num {
font-weight: 800;
}
.fme__btn {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 0;
border-radius: 4px;
padding: 0;
background: rgba(23, 20, 26, 0.14);
cursor: pointer;
color: #17141a;
}
.fme__btn:hover {
background: rgba(23, 20, 26, 0.24);
}
.fme__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 0;
}
.fme__mazeWrap {
position: relative;
}
.fme__maze {
display: grid;
gap: 4px;
justify-content: center;
grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
grid-template-areas: "c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0" "c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1" "c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2" "c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3" "c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
border-radius: 16px;
padding: 4px;
background: rgba(23, 20, 26, 0.12);
justify-self: center;
}
.fme__cell, .fme__wall {
aspect-ratio: 1/1;
min-width: 0;
border-radius: 4px;
}
.fme__wall {
background: #17141a;
}
.fme__cell {
display: grid;
place-items: center;
width: 100%;
border: 0;
padding: 0;
background: #fff7e6;
cursor: pointer;
color: #0a4fe8;
}
.fme__cell.is-start {
background: rgba(255, 247, 230, 0.6);
}
.fme__cell.is-goal {
background: #0a4fe8;
}
.fme__cell:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.fme__char {
width: 68%;
height: 68%;
opacity: 0;
transform: scale(0.6);
color: #0a4fe8;
}
.fme__cell:focus .fme__char {
opacity: 1;
transform: scale(1);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fme__cell.is-goal:focus .fme__char {
color: #fff7e6;
}
.fme__cell::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: inset 0 0 0 2px rgba(10, 79, 232, 0.5);
opacity: 0;
}
.fme__cell {
position: relative;
}
.fme__cell:focus::after {
opacity: 1;
}
.fme [hidden] {
display: none !important;
}
.fme__done {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.55);
}
.fme__donePanel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: 16px;
padding: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fme__doneTitle {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.2;
color: #17141a;
}
.fme__doneLine {
margin: 0;
font-weight: 600;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.78);
}
.fme__again {
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;
}
.fme__again:hover {
background: #2f6df6;
}
.fme__again:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.fme__hint {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.78);
}
.fme.is-demo .fme__cell:focus .fme__char {
animation-name: fmeStep;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes fmeStep {
0%, 100% {
transform: scale(1) translateY(0);
}
40% {
transform: scale(1.08) translateY(-2px);
}
}
.fme.is-demo .fme__cell.is-path {
animation-name: fmePath;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes fmePath {
0% {
background-color: #fff7e6;
}
10% {
background-color: rgba(10, 79, 232, 0.34);
}
55% {
background-color: rgba(10, 79, 232, 0.34);
}
75% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
.fme.is-demo .fme__cell.is-goal {
animation-name: fmeGoal;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fmeGoal {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.82;
transform: scale(1.06);
}
}
@media (max-width: 340px) {
.fme {
gap: 4px;
}
.fme__maze {
grid-template-columns: repeat(7, 16px);
}
.fme__donePanel {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fme.is-demo .fme__cell:focus .fme__char,
.fme.is-demo .fme__cell.is-path,
.fme.is-demo .fme__cell.is-goal {
animation: none !important;
}
.fme__cell:focus .fme__char {
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}var BEST_KEY = 'p301-06-best';
var STEP_FMT = '{n} 걸음';
var CELL_LABEL = '{c}열 {r}행 통로';
var START_LABEL = '출발 칸';
var GOAL_LABEL = '출구 칸';
// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자와 같은 배치다 (아래 style.scss 의 격자 이름이 이 표를 따른다).
var MAZE = [
'S.#...G',
'.#..#.#',
'...#..#',
'#.##..#',
'......#'
];
var COLS = 7, ROWS = 5;
// 미리보기가 걷는 경로 — 출발에서 출구까지의 최단 정답 한 벌 [열, 행] (10 걸음, run/301/_probe/maze_path.py 로 구했다)
var DEMO_PATH = [[0,0],[0,1],[0,2],[1,2],[2,2],[2,1],[3,1],[3,0],[4,0],[5,0],[6,0]];
var root = document.getElementById('fme');
var maze = document.getElementById('maze');
var stepNum = document.getElementById('stepNum');
var bestNum = document.getElementById('bestNum');
var doneBox = document.getElementById('done');
var doneLine = document.getElementById('doneLine');
var cells = {};
var steps = 0;
var best = 0;
var cleared = false;
var demoTimer = 0;
var demoIdx = 0;
function key(c, r) { return c + ',' + r; }
function at(c, r) { return (MAZE[r] || '')[c] || '#'; }
function isOpen(c, r) { return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== '#'; }
function loadBest() {
try { best = Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { best = 0; }
bestNum.textContent = best ? STEP_FMT.replace('{n}', String(best)) : '-';
}
function saveBest(n) {
if (best && n >= best) { return; }
best = n;
try { window.localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
bestNum.textContent = STEP_FMT.replace('{n}', String(best));
}
function build() {
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var ch = at(c, r);
var el;
if (ch === '#') {
// 벽은 div 라 포커스를 못 받는다 — 막히는 일이 규칙이 아니라 구조로 정해진다
el = document.createElement('div');
el.className = 'fme__wall';
el.setAttribute('aria-hidden', 'true');
} else {
el = document.createElement('button');
el.type = 'button';
el.className = 'fme__cell' + (ch === 'S' ? ' is-start' : '') + (ch === 'G' ? ' is-goal' : '');
el.setAttribute('data-c', String(c));
el.setAttribute('data-r', String(r));
var label = CELL_LABEL.replace('{c}', String(c + 1)).replace('{r}', String(r + 1));
if (ch === 'S') { label = START_LABEL; }
if (ch === 'G') { label = GOAL_LABEL; }
el.setAttribute('aria-label', label);
// 캐릭터는 칸마다 하나씩 숨어 있다가 그 칸이 :focus 가 되면 나타난다 — 그리는 주체는 CSS 다
el.innerHTML = '<svg class="fme__char" aria-hidden="true"><use href="#fme-char-ico"/></svg>';
cells[key(c, r)] = el;
}
el.style.gridArea = 'c' + c + 'r' + r;
maze.appendChild(el);
}
}
// 미리보기에서 정답 길이 순서대로 밝아지도록 경로 칸에 차례 번호를 심는다
for (var i = 0; i < DEMO_PATH.length; i++) {
var pc = cells[key(DEMO_PATH[i][0], DEMO_PATH[i][1])];
if (pc) { pc.classList.add('is-path'); pc.style.setProperty('--i', String(i)); }
}
}
// 스크립트가 옮기는 포커스인지 사람이 짚은 포커스인지 가르는 표식 — 미리보기가 자기 포커스에 스스로 멈추면 안 된다
var autoFocus = false;
function focusCell(c, r) {
var el = cells[key(c, r)];
if (!el) { return false; }
autoFocus = true;
// iframe 안에서 무가드 focus 는 부모 페이지를 끌어당긴다 — 언제나 preventScroll
el.focus({ preventScroll: true });
autoFocus = false;
return true;
}
function currentCell() {
var a = document.activeElement;
if (a && a.className && String(a.className).indexOf('fme__cell') >= 0) {
return [Number(a.getAttribute('data-c')), Number(a.getAttribute('data-r'))];
}
return null;
}
function reach(c, r) {
if (at(c, r) !== 'G' || cleared) { return; }
cleared = true;
doneLine.textContent = STEP_FMT.replace('{n}', String(steps));
doneBox.hidden = false;
saveBest(steps);
}
function move(dc, dr) {
var cur = currentCell();
if (!cur) { focusCell(0, 0); return; }
var nc = cur[0] + dc, nr = cur[1] + dr;
if (!isOpen(nc, nr)) { return; }
if (!focusCell(nc, nr)) { return; }
steps += 1;
stepNum.textContent = String(steps);
reach(nc, nr);
}
function reset() {
stopDemo();
cleared = false;
steps = 0;
stepNum.textContent = '0';
doneBox.hidden = true;
focusCell(0, 0);
}
function stopDemo() {
root.classList.remove('is-demo');
window.clearInterval(demoTimer);
demoTimer = 0;
}
window.addEventListener('keydown', function (e) {
var dc = 0, dr = 0;
if (e.key === 'ArrowLeft') { dc = -1; }
else if (e.key === 'ArrowRight') { dc = 1; }
else if (e.key === 'ArrowUp') { dr = -1; }
else if (e.key === 'ArrowDown') { dr = 1; }
else { return; }
e.preventDefault();
if (root.classList.contains('is-demo')) { stopDemo(); reset(); return; }
move(dc, dr);
});
maze.addEventListener('pointerdown', function () { if (root.classList.contains('is-demo')) { stopDemo(); reset(); } });
// 사람이 탭으로 미로에 들어오면 미리보기를 접는다 (스크립트가 옮긴 포커스는 그대로 둔다)
maze.addEventListener('focusin', function () {
if (!autoFocus && root.classList.contains('is-demo')) { stopDemo(); reset(); }
});
document.getElementById('restartBtn').addEventListener('click', function () { reset(); });
document.getElementById('againBtn').addEventListener('click', function () { reset(); });
// 미리보기 — 타이머가 정답 경로를 따라 포커스를 옮겨 캐릭터가 미로를 걷는다.
// 스크롤은 preventScroll 로 막고, 사람이 키를 누르거나 칸을 누르면 바로 멈춘다.
function startDemo() {
demoIdx = 0;
demoTimer = window.setInterval(function () {
if (!root.classList.contains('is-demo')) { window.clearInterval(demoTimer); return; }
var p = DEMO_PATH[demoIdx % DEMO_PATH.length];
focusCell(p[0], p[1]);
demoIdx += 1;
}, 133);
}
loadBest();
build();
focusCell(0, 0);
if (root.classList.contains('is-demo')) { startDemo(); }// FocusMazeEscape — vanilla 판(run/301/demo/06-focus-maze-escape) 의 React 포트.
// 통로 칸은 button, 벽은 div — 벽이 포커스를 못 받는 것이 곧 "막힌다" 는 규칙이다.
// keydown 이 인접 통로 칸으로 focus({preventScroll:true}) 를 옮기고 캐릭터는 :focus 칸에 나타난다.
// duration·easing·accentColor 는 props → CSS 변수로 들어온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FocusMazeEscape.module.scss";
export type FocusMazeEscapeProps = {
duration?: string;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
const BEST_KEY = "p301-06-best";
const STEP_FMT = "{n} 걸음";
const CELL_LABEL = "{c}열 {r}행 통로";
const START_LABEL = "출발 칸";
const GOAL_LABEL = "출구 칸";
const TITLE_TEXT = "포커스 미로 탈출";
const MAZE_LABEL = "7×5 미로. 방향키로 움직이세요";
const STEPS_LABEL = "걸음";
const AGAIN_LABEL = "다시 시작";
const DONE_TITLE = "탈출!";
const HINT_TEXT = "방향키로 칸을 옮겨 깃발까지 가세요 · 벽은 포커스를 받지 않아 저절로 막힙니다";
// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자(module.scss)와 같은 배치다.
const MAZE: string[] = [
"S.#...G",
".#..#.#",
"...#..#",
"#.##..#",
"......#",
];
const COLS = 7, ROWS = 5;
// 미리보기가 걷는 경로 — 출발에서 출구까지의 최단 정답 한 벌 [열, 행] (10 걸음)
const DEMO_PATH: number[][] = [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [3, 1], [3, 0], [4, 0], [5, 0], [6, 0]];
function at(c: number, r: number): string { return (MAZE[r] || "")[c] || "#"; }
function isOpen(c: number, r: number): boolean {
return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== "#";
}
function cellKey(c: number, r: number): string { return "fme-cell-" + c + "-" + r; }
export default function FocusMazeEscape({ duration, easing, accentColor }: FocusMazeEscapeProps) {
const [isDemo, setIsDemo] = useState(true);
const [steps, setSteps] = useState(0);
const [best, setBest] = useState(0);
const [cleared, setCleared] = useState(false);
const [demoIdx, setDemoIdx] = useState(0);
const g = useRef({ steps: 0, cleared: false, best: 0, demoIdx: 0, autoFocus: false }) as
Box<{ steps: number; cleared: boolean; best: number; demoIdx: number; autoFocus: boolean }>;
useEffect(() => {
try { g.current.best = Number(window.localStorage.getItem(BEST_KEY)) || 0; setBest(g.current.best); }
catch (e) { g.current.best = 0; setBest(0); }
// 시작 칸에 캐릭터를 올려 둔다 — 사람이 탭으로 들어와도 첫 칸이 이미 살아 있다
window.requestAnimationFrame(() => { focusCell(0, 0); });
}, []);
const focusCell = (c: number, r: number): boolean => {
const el = document.getElementById(cellKey(c, r));
if (!el) { return false; }
g.current.autoFocus = true;
// iframe 안에서 무가드 focus 는 부모 페이지를 끌어당긴다 — 언제나 preventScroll
el.focus({ preventScroll: true });
g.current.autoFocus = false;
return true;
};
const currentCell = (): number[] | null => {
const a = document.activeElement as HTMLElement | null;
if (a && a.hasAttribute("data-c")) {
return [Number(a.getAttribute("data-c")), Number(a.getAttribute("data-r"))];
}
return null;
};
const saveBest = (n: number) => {
if (g.current.best && n >= g.current.best) { return; }
g.current.best = n;
setBest(n);
try { window.localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
};
const reach = (c: number, r: number) => {
if (at(c, r) !== "G" || g.current.cleared) { return; }
g.current.cleared = true;
setCleared(true);
saveBest(g.current.steps);
};
const move = (dc: number, dr: number) => {
const cur = currentCell();
if (!cur) { focusCell(0, 0); return; }
const nc = cur[0] + dc, nr = cur[1] + dr;
if (!isOpen(nc, nr) || !focusCell(nc, nr)) { return; }
g.current.steps += 1;
setSteps(g.current.steps);
reach(nc, nr);
};
const reset = () => {
setIsDemo(false);
g.current.cleared = false;
g.current.steps = 0;
setCleared(false);
setSteps(0);
focusCell(0, 0);
};
useEffect(() => {
const onKey = (e: KeyEvt) => {
let dc = 0, dr = 0;
if (e.key === "ArrowLeft") { dc = -1; }
else if (e.key === "ArrowRight") { dc = 1; }
else if (e.key === "ArrowUp") { dr = -1; }
else if (e.key === "ArrowDown") { dr = 1; }
else { return; }
e.preventDefault();
if (g.current.autoFocus) { return; }
if (isDemoRef.current) { reset(); return; }
move(dc, dr);
};
const handler = onKey as unknown as (e: Event) => void;
window.addEventListener("keydown", handler);
return () => { window.removeEventListener("keydown", handler); };
}, []);
const isDemoRef = useRef(true) as Box<boolean>;
useEffect(() => { isDemoRef.current = isDemo; }, [isDemo]);
// 미리보기 — 타이머가 정답 경로를 따라 포커스를 옮겨 캐릭터가 미로를 걷는다.
// 사람이 키를 누르거나 칸을 누르면 reset() 이 미리보기를 뗀다.
useEffect(() => {
if (!isDemo) { return; }
const id = window.setInterval(() => {
const p = DEMO_PATH[g.current.demoIdx % DEMO_PATH.length];
g.current.demoIdx += 1;
focusCell(p[0], p[1]);
setDemoIdx(g.current.demoIdx);
}, 133);
return () => { window.clearInterval(id); };
}, [isDemo]);
// 사람이 탭으로 미로에 들어오면 미리보기를 접는다 (스크립트가 옮긴 포커스는 그대로 둔다)
const onFocusIn = () => {
if (!g.current.autoFocus && isDemoRef.current) { reset(); }
};
const vars: Vars = {};
if (duration) { vars["--duration"] = duration; }
if (easing) { vars["--easing"] = easing; }
if (accentColor) { vars["--color"] = accentColor; }
const grid: { c: number; r: number; ch: string }[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) { grid.push({ c, r, ch: at(c, r) }); }
}
const pathIdx = (c: number, r: number): number => {
for (let i = 0; i < DEMO_PATH.length; i++) {
if (DEMO_PATH[i][0] === c && DEMO_PATH[i][1] === r) { return i; }
}
return -1;
};
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="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2" />
<path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z" />
</svg>
<span>{TITLE_TEXT}</span>
</p>
<p className={styles.pill}><span className={styles.label}>{STEPS_LABEL}</span><b className={styles.num}>{steps}</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 ? STEP_FMT.replace("{n}", String(best)) : "-"}</b>
</p>
<button className={styles.btn} type="button" aria-label={AGAIN_LABEL} onClick={reset}>
<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.mazeWrap}>
<div className={styles.maze} role="group" aria-label={MAZE_LABEL} onFocus={onFocusIn}
onPointerDown={() => { if (isDemoRef.current) { reset(); } }}>
{grid.map(({ c, r, ch }) => {
const area = { gridArea: "c" + c + "r" + r } as CSSProperties;
if (ch === "#") {
return <div className={styles.wall} key={c + "-" + r} aria-hidden="true" style={area} />;
}
const pi = pathIdx(c, r);
const cls = [styles.cell,
ch === "S" ? styles.start : "",
ch === "G" ? styles.goal : "",
isDemo && pi >= 0 ? styles.path : ""].filter(Boolean).join(" ");
const label = ch === "S" ? START_LABEL : (ch === "G" ? GOAL_LABEL : CELL_LABEL.replace("{c}", String(c + 1)).replace("{r}", String(r + 1)));
const style: CSSProperties = pi >= 0 ? Object.assign({}, area, { "--i": pi } as CSSProperties) : area;
return (
<button key={c + "-" + r} id={cellKey(c, r)} type="button" className={cls}
aria-label={label} data-c={c} data-r={r} style={style}>
<svg className={styles.char} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M176 48a24 24 0 1 1-24-24a24 24 0 0 1 24 24" opacity=".2" />
<path d="M152 80a32 32 0 1 0-32-32a32 32 0 0 0 32 32m0-48a16 16 0 1 1-16 16a16 16 0 0 1 16-16m64 112a8 8 0 0 1-8 8c-35.31 0-52.95-17.81-67.12-32.12c-2.74-2.77-5.36-5.4-8-7.84l-13.43 30.88l37.2 26.57A8 8 0 0 1 160 176v56a8 8 0 0 1-16 0v-51.88l-31.07-22.2l-33.59 77.27A8 8 0 0 1 72 240a7.8 7.8 0 0 1-3.19-.67a8 8 0 0 1-4.15-10.52l54.08-124.37c-9.31-1.65-20.92 1.2-34.7 8.58a164 164 0 0 0-30.57 21.77a8 8 0 0 1-10.95-11.66c2.5-2.35 61.69-57.22 98.72-25.08c3.83 3.32 7.48 7 11 10.58C166.19 122.7 179.36 136 208 136a8 8 0 0 1 8 8" />
</svg>
</button>
);
})}
</div>
<div className={styles.done} hidden={!cleared}>
<div className={styles.donePanel}>
<p className={styles.doneTitle}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2" />
<path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87" />
</svg>
<span>{DONE_TITLE}</span>
</p>
<p className={styles.doneLine}>{STEP_FMT.replace("{n}", String(steps))}</p>
<button className={styles.again} type="button" onClick={reset}>
<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>
</div>
<p className={styles.hint}>{HINT_TEXT}</p>
</div>
);
}06 · 포커스 미로 탈출 — 측정 누적면적 7.31% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
미로는 문자 다섯 줄
// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자와 같은 배치다 (아래 style.scss 의 격자 이름이 이 표를 따른다).
var MAZE = [
'S.#...G',
'.#..#.#',
'...#..#',
'#.##..#',
'......#'
];
var COLS = 7, ROWS = 5;
function at(c, r) { return (MAZE[r] || '')[c] || '#'; }
function isOpen(c, r) { return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== '#'; }
통로는 button 으로, 벽은 div 로 만들기 때문에 "막힌다" 는 규칙을 따로 쓸 필요가 없고, 미로를 바꾸고 싶으면 이 문자 다섯 줄만 고치면 됩니다.
캐릭터를 그리는 건 :focus 선택자
.fme__maze {
display: grid; gap: $sp-1; justify-content: center;
grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
grid-template-areas:
"c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0"
"c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1"
"c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2"
"c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3"
"c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
border-radius: $r-card; padding: $sp-1; background: rgba($subject-ink, .12);
justify-self: center;
}
// 캐릭터 — 포커스를 받은 칸에서만 보인다. 벽은 button 이 아니라 포커스를 못 받으므로 갈 수 없다
.fme__char { width: 68%; height: 68%; opacity: 0; transform: scale(.6); color: $color; }
.fme__cell:focus .fme__char {
opacity: 1; transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.fme__cell:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
칸마다 숨어 있던 캐릭터가 포커스를 받은 칸에서만 나타나므로 JS 가 옮기는 것은 포커스 하나뿐이고, :focus-visible 을 따로 둔 덕에 테두리는 키보드로 짚었을 때만 섭니다.
07떨어지는 것 피하기
바닥의 로켓을 좌우로 몰아 떨어지는 장애물을 피하고 세 번 맞으면 끝나는, 이벤트 랜딩의 놀이 코너나 반응 속도를 내세우는 UI 에 맞는 판입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-game" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2"/><path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/></g></symbol>
<symbol id="ico-trophy" 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="ico-restart" 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>
<symbol id="ico-heart" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></symbol>
<symbol id="ico-rocket" viewBox="0 0 256 256" fill="currentColor"><g><path d="m94.81 192l-29.45 22.24a8 8 0 0 1-12.81-4.51L40.19 154.1a8 8 0 0 1 1.66-6.86l30.31-36.33C71 134.25 76.7 161.43 94.81 192m119.34-44.76l-30.31-36.33c1.21 23.34-4.54 50.52-22.65 81.09l29.45 22.24a8 8 0 0 0 12.81-4.51l12.36-55.63a8 8 0 0 0-1.66-6.86" opacity=".2"/><path d="M152 224a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8m-24-112a12 12 0 1 0-12-12a12 12 0 0 0 12 12m95.62 43.83l-12.36 55.63a16 16 0 0 1-25.51 9.11L158.51 200h-61l-27.26 20.57a16 16 0 0 1-25.51-9.11l-12.36-55.63a16.09 16.09 0 0 1 3.32-13.71l28.56-34.26a123 123 0 0 1 8.57-36.67c12.9-32.34 36-52.63 45.37-59.85a16 16 0 0 1 19.6 0c9.34 7.22 32.47 27.51 45.37 59.85a123 123 0 0 1 8.57 36.67l28.56 34.26a16.09 16.09 0 0 1 3.32 13.71M99.43 184h57.14c21.12-37.54 25.07-73.48 11.74-106.88C156.55 47.64 134.49 29 128 24c-6.51 5-28.57 23.64-40.33 53.12c-13.31 33.4-9.36 69.34 11.76 106.88m-15 5.85q-16.15-29.35-19.6-57.69L48 152.36L60.36 208l.18-.13ZM208 152.36l-16.83-20.2q-3.42 28.28-19.56 57.69l23.85 18l.18.13Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="df is-demo" id="df">
<header class="df__hud">
<p class="df__stat">
<svg class="df__ico" aria-hidden="true"><use href="#ico-game"/></svg>
<span class="df__label">점수</span><b class="df__num" id="dfScore" aria-live="polite">0</b>
</p>
<p class="df__lives" id="dfLives" aria-label="남은 목숨 3">
<i class="df__life is-on"><svg aria-hidden="true"><use href="#ico-heart"/></svg></i>
<i class="df__life is-on"><svg aria-hidden="true"><use href="#ico-heart"/></svg></i>
<i class="df__life is-on"><svg aria-hidden="true"><use href="#ico-heart"/></svg></i>
</p>
<p class="df__stat">
<svg class="df__ico" aria-hidden="true"><use href="#ico-trophy"/></svg>
<span class="df__label">최고기록</span><b class="df__num" id="dfBest">0</b>
</p>
</header>
<div class="df__field" id="dfField" tabindex="0" role="application" aria-label="떨어지는 장애물 피하기 놀이판">
<i class="df__rock df__rock--demo" style="--x0: 16%; --y0: 22%; --d0: 0ms"></i>
<i class="df__rock df__rock--demo" style="--x0: 40%; --y0: 34%; --d0: -500ms"></i>
<i class="df__rock df__rock--demo" style="--x0: 62%; --y0: 58%; --d0: -1000ms"></i>
<i class="df__rock df__rock--demo" style="--x0: 84%; --y0: 70%; --d0: -1500ms"></i>
<span class="df__player" id="dfPlayer">
<svg class="df__rocket" aria-hidden="true"><use href="#ico-rocket"/></svg>
</span>
<p class="df__hint" id="dfHint">방향키 또는 A 와 D 로 움직여 장애물을 피하세요</p>
<div class="df__over" id="dfOver" hidden>
<div class="df__overPanel">
<p class="df__overTitle">게임 끝</p>
<p class="df__overScore"><b id="dfOverScore">0개 피했습니다</b></p>
<button class="df__restart" id="dfRestart" type="button">
<svg class="df__ico" aria-hidden="true"><use href="#ico-restart"/></svg><span>다시 시작</span>
</button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 떨어지는 것 피하기 — 바닥의 로켓을 좌우(방향키 · A/D · 포인터)로 움직여 낙하 장애물을 피한다.
// 장애물 낙하는 CSS animation(transform: translateY), 로켓 이동은 requestAnimationFrame 의
// transform: translateX, 충돌은 getBoundingClientRect 겹침 검사. 목숨은 3, 점수는 피한 장애물 수.
// .is-demo 는 사람이 조작하기 전의 미리보기(장애물 낙하 + 로켓 회피 스윙)를 CSS keyframes 로만
// 재생하고 손이 닿는 순간 JS 가 .is-demo 를 뗀다. 미리보기를 뗀 정적 화면도 완결된 상태다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
// 주황 무대 위 글자는 전부 잉크 — 크림 글자는 3.11:1 로 AA 미달이다 (blog-design 대비 표)
.df { display: flex; flex-direction: column; gap: $sp-2; width: 100%; max-width: 440px; }
.df__hud { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.df__hud > p { display: flex; align-items: center; gap: $sp-1; margin: 0; }
.df__ico { width: 15px; height: 15px; flex: 0 0 auto; color: $color; }
.df__label { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: $color; }
.df__num {
font-weight: 800; font-size: clamp(13px, 3.6vw, 17px); line-height: 1;
color: $color; font-variant-numeric: tabular-nums;
}
.df__lives { display: flex; align-items: center; gap: $sp-1; }
.df__life {
width: 13px; height: 13px; color: $color; opacity: .18;
transition: opacity $dur-quick $ease-out;
}
.df__life svg { display: block; width: 100%; height: 100%; }
.df__life.is-on { opacity: 1; }
.df__field {
position: relative; overflow: hidden; // 낙하 장애물이 문서 scrollHeight 를 늘리지 않게 가둔다 (BD9)
height: clamp(96px, 56vh, 188px);
border: 2px solid rgba($color, .30);
border-radius: $r-card;
background: rgba($color, .07);
touch-action: none;
cursor: pointer;
}
.df__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 바닥선 — 로켓이 서는 지면
.df__field::after {
content: ""; position: absolute; left: $sp-2; right: $sp-2; bottom: $sp-1; height: 2px;
border-radius: $r-pill; background: rgba($color, .30);
}
.df__player {
position: absolute; left: 0; bottom: 9px;
width: 36px; height: 36px;
display: grid; place-items: center;
background: rgba($subject-cream, .16);
border-radius: 50%;
will-change: transform;
}
.df__rocket { width: 26px; height: 26px; color: $subject-cream; transform: rotate(-45deg); }
.df__rock {
position: absolute; top: -34px;
width: 24px; height: 24px;
border-radius: $r-xs;
background: $color;
will-change: transform;
animation-name: dfFall;
animation-duration: var(--t, 1200ms);
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes dfFall {
from { transform: translateY(0); }
to { transform: translateY(340px); }
}
.df__player.is-hit {
animation-name: dfHit;
animation-duration: 160ms;
animation-iteration-count: 4;
animation-timing-function: $ease-out;
}
@keyframes dfHit {
0%, 100% { opacity: 1; }
50% { opacity: .12; }
}
.df__hint {
position: absolute; left: 50%; top: $sp-2; transform: translateX(-50%);
margin: 0; padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $stage-paper; color: $color;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
white-space: nowrap;
}
.df__hint[hidden] { display: none; }
.df__over {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba($stage-ink, .60);
}
.df__over[hidden] { display: none; }
.df__overPanel {
display: grid; justify-items: center; gap: $sp-2;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.df__overTitle {
margin: 0; font-weight: 800; font-size: clamp(14px, 4vw, 18px); line-height: 1.2; color: $subject-ink;
}
.df__overScore {
margin: 0;
font-weight: 800; font-size: clamp(15px, 4.4vw, 20px); line-height: 1.2;
color: $subject-orange-deep; // 크림 면 위 주황 글자는 진한 계열토큰만 통과 (5.04:1)
}
.df__restart {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-blue-deep; color: $subject-cream; // 파란 면에 크림 글자는 deep 토큰 (5.98:1)
cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.6;
transition: transform $dur-quick $ease-pop;
}
.df__restart .df__ico { color: $subject-cream; }
.df__restart:hover { transform: translateY(-2px) scale(1.04); }
.df__restart:active { transform: translateY(0) scale(.97); }
.df__restart:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 미리보기(.is-demo): 장애물 낙하 + 로켓 회피 스윙 ──────────────
// 장애물 네 개는 서로 다른 위상(음수 delay)으로 떨어져 2초 내내 움직임이 고루 분포한다.
.df.is-demo .df__rock--demo {
left: var(--x0, 20%);
animation-name: dfFallDemo;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: var(--d0, 0ms);
animation-fill-mode: both;
}
@keyframes dfFallDemo {
from { transform: translateY(0); }
to { transform: translateY(320px); }
}
.df.is-demo .df__player {
animation-name: dfSway;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// --cx 는 JS 가 놓는 로켓 중심 위치. 장애물을 옆으로 비껴가는 회피 동작으로 보인다.
@keyframes dfSway {
0% { transform: translateX(var(--cx, 150px)); }
24% { transform: translateX(calc(var(--cx, 150px) - 92px)); }
52% { transform: translateX(calc(var(--cx, 150px) + 12px)); }
78% { transform: translateX(calc(var(--cx, 150px) + 86px)); }
100% { transform: translateX(var(--cx, 150px)); }
}
@media (max-width: 340px) {
.df { gap: $sp-1; }
.df__field { height: clamp(88px, 48vh, 140px); }
.df__player { width: 30px; height: 30px; }
.df__rocket { width: 22px; height: 22px; }
.df__rock { width: 20px; height: 20px; }
.df__overPanel { padding: $sp-2 $sp-3; gap: $sp-1; }
}
// reduce 에서는 연속 모션을 끈다 — 미리보기 장애물은 제자리에 점등식으로 보이고,
// 실제 게임의 낙하는 8단계 점등식(steps)으로 떨어진다. 피격 깜빡임도 끄고 반투명 고정으로 알린다.
@media (prefers-reduced-motion: reduce) {
.df.is-demo .df__rock--demo { animation: none !important; top: var(--y0, 40%) !important; }
.df.is-demo .df__player { animation: none !important; }
.df__rock { animation-timing-function: steps(8, end) !important; }
.df__player.is-hit { animation: none !important; opacity: .35 !important; }
.df__life, .df__restart { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.df {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.df__hud {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.df__hud > p {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.df__ico {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: #17141a;
}
.df__label {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: #17141a;
}
.df__num {
font-weight: 800;
font-size: clamp(13px, 3.6vw, 17px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.df__lives {
display: flex;
align-items: center;
gap: 4px;
}
.df__life {
width: 13px;
height: 13px;
color: #17141a;
opacity: 0.18;
transition: opacity 220ms ease-out;
}
.df__life svg {
display: block;
width: 100%;
height: 100%;
}
.df__life.is-on {
opacity: 1;
}
.df__field {
position: relative;
overflow: hidden;
height: clamp(96px, 56vh, 188px);
border: 2px solid rgba(23, 20, 26, 0.3);
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
touch-action: none;
cursor: pointer;
}
.df__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.df__field::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 4px;
height: 2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.3);
}
.df__player {
position: absolute;
left: 0;
bottom: 9px;
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.16);
border-radius: 50%;
will-change: transform;
}
.df__rocket {
width: 26px;
height: 26px;
color: #fff7e6;
transform: rotate(-45deg);
}
.df__rock {
position: absolute;
top: -34px;
width: 24px;
height: 24px;
border-radius: 4px;
background: #17141a;
will-change: transform;
animation-name: dfFall;
animation-duration: var(--t, 1200ms);
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes dfFall {
from {
transform: translateY(0);
}
to {
transform: translateY(340px);
}
}
.df__player.is-hit {
animation-name: dfHit;
animation-duration: 160ms;
animation-iteration-count: 4;
animation-timing-function: ease-out;
}
@keyframes dfHit {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.12;
}
}
.df__hint {
position: absolute;
left: 50%;
top: 8px;
transform: translateX(-50%);
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
white-space: nowrap;
}
.df__hint[hidden] {
display: none;
}
.df__over {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.6);
}
.df__over[hidden] {
display: none;
}
.df__overPanel {
display: grid;
justify-items: center;
gap: 8px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.df__overTitle {
margin: 0;
font-weight: 800;
font-size: clamp(14px, 4vw, 18px);
line-height: 1.2;
color: #17141a;
}
.df__overScore {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 20px);
line-height: 1.2;
color: #c93000;
}
.df__restart {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #0a4fe8;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.6;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.df__restart .df__ico {
color: #fff7e6;
}
.df__restart:hover {
transform: translateY(-2px) scale(1.04);
}
.df__restart:active {
transform: translateY(0) scale(0.97);
}
.df__restart:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.df.is-demo .df__rock--demo {
left: var(--x0, 20%);
animation-name: dfFallDemo;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-delay: var(--d0, 0ms);
animation-fill-mode: both;
}
@keyframes dfFallDemo {
from {
transform: translateY(0);
}
to {
transform: translateY(320px);
}
}
.df.is-demo .df__player {
animation-name: dfSway;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes dfSway {
0% {
transform: translateX(var(--cx, 150px));
}
24% {
transform: translateX(calc(var(--cx, 150px) - 92px));
}
52% {
transform: translateX(calc(var(--cx, 150px) + 12px));
}
78% {
transform: translateX(calc(var(--cx, 150px) + 86px));
}
100% {
transform: translateX(var(--cx, 150px));
}
}
@media (max-width: 340px) {
.df {
gap: 4px;
}
.df__field {
height: clamp(88px, 48vh, 140px);
}
.df__player {
width: 30px;
height: 30px;
}
.df__rocket {
width: 22px;
height: 22px;
}
.df__rock {
width: 20px;
height: 20px;
}
.df__overPanel {
padding: 8px 12px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.df.is-demo .df__rock--demo {
animation: none !important;
top: var(--y0, 40%) !important;
}
.df.is-demo .df__player {
animation: none !important;
}
.df__rock {
animation-timing-function: steps(8, end) !important;
}
.df__player.is-hit {
animation: none !important;
opacity: 0.35 !important;
}
.df__life, .df__restart {
transition: none !important;
}
}// 떨어지는 것 피하기 — 장애물 낙하는 CSS animation(transform: translateY),
// 로켓 이동은 requestAnimationFrame 의 transform: translateX,
// 충돌은 getBoundingClientRect 겹침 검사. 목숨 3, 점수는 피한 장애물 수.
var BEST_KEY = 'p301-07-best';
var OVER_SCORE_TEXT = '{n}개 피했습니다';
var LIVES_ARIA = '남은 목숨 {n}';
var root = document.getElementById('df');
var field = document.getElementById('dfField');
var player = document.getElementById('dfPlayer');
var scoreEl = document.getElementById('dfScore');
var bestEl = document.getElementById('dfBest');
var livesWrap = document.getElementById('dfLives');
var hintEl = document.getElementById('dfHint');
var overEl = document.getElementById('dfOver');
var overScoreEl = document.getElementById('dfOverScore');
var restartBtn = document.getElementById('dfRestart');
var PLAYER_SPEED = 250; // 초당 픽셀
var START_LIVES = 3;
var running = false;
var lives = START_LIVES;
var score = 0;
var best = loadBest();
var px = 0; // 로켓 왼쪽 끝 위치
var pointerX = null; // 포인터 조작의 목표 x
var keys = { left: false, right: false };
var rocks = [];
var spawnTimer = 0;
var invulnUntil = 0;
var rafId = 0;
var lastT = 0;
function loadBest() {
try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; }
}
function saveBest(n) {
try { localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
function clampPx(v) {
var w = field.clientWidth - player.offsetWidth;
return Math.max(0, Math.min(w, v));
}
function centerPx() {
return Math.max(0, (field.clientWidth - player.offsetWidth) / 2);
}
function paintLives() {
var hearts = livesWrap.children;
for (var i = 0; i < hearts.length; i++) { hearts[i].classList.toggle('is-on', i < lives); }
livesWrap.setAttribute('aria-label', LIVES_ARIA.replace('{n}', String(lives)));
}
function paintHud() {
scoreEl.textContent = String(score);
bestEl.textContent = String(best);
}
function placePlayer() {
px = clampPx(px);
player.style.transform = 'translateX(' + Math.round(px) + 'px)';
}
function spawnRock() {
if (!running) { return; }
var rock = document.createElement('i');
rock.className = 'df__rock';
var size = 20 + Math.floor(Math.random() * 11);
rock.style.width = size + 'px';
rock.style.height = size + 'px';
rock.style.left = (4 + Math.random() * 84) + '%';
rock.style.setProperty('--t', Math.max(820, 1500 - score * 14) + 'ms');
rock.addEventListener('animationend', function () { dodge(rock); });
field.appendChild(rock);
rocks.push(rock);
spawnTimer = setTimeout(spawnRock, Math.max(320, 720 - score * 9));
}
function dodge(rock) {
rock.remove();
var i = rocks.indexOf(rock);
if (i >= 0) { rocks.splice(i, 1); }
if (!running) { return; }
score += 1;
if (score > best) { best = score; saveBest(best); }
paintHud();
}
function hitRock(rock) {
rock.remove();
var i = rocks.indexOf(rock);
if (i >= 0) { rocks.splice(i, 1); }
lives -= 1;
paintLives();
invulnUntil = performance.now() + 900;
player.classList.remove('is-hit');
void player.offsetWidth; // 피격 깜빡임 애니메이션을 처음부터 다시 트는 재정렬 트릭
player.classList.add('is-hit');
if (lives <= 0) { gameOver(); }
}
function rectsOverlap(a, b) {
return a.left < b.right - 2 && a.right > b.left + 2 && a.top < b.bottom - 4 && a.bottom > b.top + 4;
}
function tick(now) {
if (!running) { return; }
var dt = Math.min(64, now - lastT) / 1000;
lastT = now;
var dir = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
if (dir !== 0) {
pointerX = null;
px += dir * PLAYER_SPEED * dt;
} else if (pointerX !== null) {
var d = pointerX - px;
var step = PLAYER_SPEED * dt;
px += Math.abs(d) <= step ? d : Math.sign(d) * step;
}
placePlayer();
if (now >= invulnUntil) {
var pr = player.getBoundingClientRect();
var hit = { left: pr.left + 5, right: pr.right - 5, top: pr.top + 6, bottom: pr.bottom - 4 };
for (var i = rocks.length - 1; i >= 0; i--) {
if (rectsOverlap(hit, rocks[i].getBoundingClientRect())) { hitRock(rocks[i]); break; }
}
}
rafId = requestAnimationFrame(tick);
}
function startRun() {
if (running) { return; }
running = true;
root.classList.remove('is-demo');
var demoRocks = field.querySelectorAll('.df__rock--demo');
for (var i = 0; i < demoRocks.length; i++) { demoRocks[i].remove(); }
hintEl.hidden = true;
overEl.hidden = true;
lastT = performance.now();
rafId = requestAnimationFrame(tick);
spawnTimer = setTimeout(spawnRock, 350);
field.focus({ preventScroll: true });
}
function gameOver() {
running = false;
clearTimeout(spawnTimer);
cancelAnimationFrame(rafId);
for (var i = 0; i < rocks.length; i++) { rocks[i].remove(); }
rocks = [];
saveBest(best);
overScoreEl.textContent = OVER_SCORE_TEXT.replace('{n}', String(score));
overEl.hidden = false;
restartBtn.focus({ preventScroll: true });
}
restartBtn.addEventListener('click', function () {
lives = START_LIVES;
score = 0;
paintLives();
paintHud();
px = centerPx();
placePlayer();
startRun();
});
window.addEventListener('keydown', function (e) {
var k = e.key;
if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
keys.left = true;
e.preventDefault();
} else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
keys.right = true;
e.preventDefault();
} else {
return;
}
if (!running && overEl.hidden) { startRun(); }
});
window.addEventListener('keyup', function (e) {
var k = e.key;
if (k === 'ArrowLeft' || k === 'a' || k === 'A') { keys.left = false; }
else if (k === 'ArrowRight' || k === 'd' || k === 'D') { keys.right = false; }
});
function pointerTarget(e) {
var r = field.getBoundingClientRect();
pointerX = clampPx(e.clientX - r.left - player.offsetWidth / 2);
}
field.addEventListener('pointerdown', function (e) {
if (!overEl.hidden || running) { return; }
startRun();
pointerTarget(e);
});
field.addEventListener('pointermove', function (e) {
if (!running) { return; }
pointerTarget(e);
});
window.addEventListener('resize', function () {
px = clampPx(px);
placePlayer();
});
paintLives();
paintHud();
px = centerPx();
placePlayer();
player.style.setProperty('--cx', Math.round(centerPx()) + 'px');// DodgeFallingRun — vanilla 판(run/301/demo/07-dodge-falling-run/index.html) 의 React 포트.
// 장애물 낙하는 CSS animation(translateY), 로켓 이동은 requestAnimationFrame 의 translateX,
// 충돌은 getBoundingClientRect 겹침 검사. 값·규칙은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./DodgeFallingRun.module.scss";
export type DodgeFallingRunProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Rock = { id: number; leftPct: number; size: number; fallMs: number };
type Vars = { [k: string]: string | undefined };
// ambient.d.ts 의 useRef 는 { current: T | null } 을 돌려주므로 값 보관용 참조는 null 없는 상자로 단언한다
type Box<T> = { current: T };
const BEST_KEY = "p301-07-best";
const OVER_SCORE_TEXT = "{n}개 피했습니다";
const LIVES_ARIA = "남은 목숨 {n}";
const HINT = "방향키 또는 A 와 D 로 움직여 장애물을 피하세요";
const FIELD_ARIA = "떨어지는 장애물 피하기 놀이판";
const LABEL_SCORE = "점수";
const LABEL_BEST = "최고기록";
const OVER_TITLE = "게임 끝";
const RESTART = "다시 시작";
const PLAYER_SPEED = 250; // 초당 픽셀
const START_LIVES = 3;
function loadBest(): number {
try { return Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; }
}
function saveBest(n: number) {
try { window.localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
export default function DodgeFallingRun({
duration = 2000,
easing = "ease-out",
accentColor = "#17141a",
}: DodgeFallingRunProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<HTMLSpanElement | null>(null);
const restartRef = useRef<HTMLButtonElement | null>(null);
const keys = useRef({ left: false, right: false }) as Box<{ left: boolean; right: boolean }>;
const pointerX = useRef(null) as Box<number | null>;
const px = useRef(0) as Box<number>;
const invulnUntil = useRef(0) as Box<number>;
const rockId = useRef(0) as Box<number>;
const g = useRef({ score: 0, best: 0, lives: START_LIVES, running: false, over: false }) as Box<{
score: number; best: number; lives: number; running: boolean; over: boolean;
}>;
const [rocks, setRocks] = useState<Rock[]>([]);
const [score, setScore] = useState(0);
const [best, setBest] = useState(0);
const [lives, setLives] = useState(START_LIVES);
const [running, setRunning] = useState(false);
const [over, setOver] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--accent": accentColor };
const demoVars = (x0: string, y0: string, d0: string): Vars => ({ "--x0": x0, "--y0": y0, "--d0": d0 });
function place() {
const field = fieldRef.current, player = playerRef.current;
if (!field || !player) { return; }
const w = field.clientWidth - player.offsetWidth;
px.current = Math.max(0, Math.min(w, px.current));
player.style.transform = "translateX(" + Math.round(px.current) + "px)";
}
function setPointerFrom(e: ReactPointerEvent<HTMLDivElement>) {
const field = fieldRef.current, player = playerRef.current;
if (!field || !player) { return; }
const r = field.getBoundingClientRect();
const w = field.clientWidth - player.offsetWidth;
pointerX.current = Math.max(0, Math.min(w, e.clientX - r.left - player.offsetWidth / 2));
}
function removeRock(id: number) {
setRocks(rs => rs.filter(r => r.id !== id));
}
function dodge(id: number) {
removeRock(id);
if (!g.current.running) { return; }
g.current.score += 1;
setScore(g.current.score);
if (g.current.score > g.current.best) {
g.current.best = g.current.score;
setBest(g.current.best);
saveBest(g.current.best);
}
}
function damage(now: number) {
g.current.lives -= 1;
setLives(g.current.lives);
invulnUntil.current = now + 900;
const player = playerRef.current;
if (player) {
player.classList.remove("is-hit");
void player.offsetWidth; // 피격 깜빡임 애니메이션을 처음부터 다시 트는 재정렬 트릭
player.classList.add("is-hit");
}
if (g.current.lives <= 0) { endGame(); }
}
function endGame() {
g.current.running = false;
g.current.over = true;
setRunning(false);
setRocks([]);
saveBest(g.current.best);
setOver(true);
}
function startRun() {
if (g.current.running) { return; }
g.current.running = true;
g.current.over = false;
setDemo(false);
setOver(false);
setRunning(true);
const field = fieldRef.current;
if (field) { field.focus({ preventScroll: true }); }
}
function restart() {
g.current.score = 0;
g.current.lives = START_LIVES;
setScore(0);
setLives(START_LIVES);
const field = fieldRef.current;
if (field) { px.current = Math.max(0, (field.clientWidth - 36) / 2); }
place();
startRun();
}
// 최초 배치 + 키보드·리사이즈
useEffect(() => {
g.current.best = loadBest();
setBest(g.current.best);
const field = fieldRef.current, player = playerRef.current;
if (field && player) {
px.current = Math.max(0, (field.clientWidth - player.offsetWidth) / 2);
place();
player.style.setProperty("--cx", Math.round(px.current) + "px");
}
const down = (e: KeyboardEvent) => {
const k = e.key;
if (k === "ArrowLeft" || k === "a" || k === "A") { keys.current.left = true; e.preventDefault(); }
else if (k === "ArrowRight" || k === "d" || k === "D") { keys.current.right = true; e.preventDefault(); }
else { return; }
if (!g.current.running && !g.current.over) { startRun(); }
};
const up = (e: KeyboardEvent) => {
const k = e.key;
if (k === "ArrowLeft" || k === "a" || k === "A") { keys.current.left = false; }
else if (k === "ArrowRight" || k === "d" || k === "D") { keys.current.right = false; }
};
const onResize = () => place();
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("resize", onResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 장애물 스폰 (점수가 오를수록 촘촘하게)
useEffect(() => {
if (!running) { return; }
let alive = true;
let t = 0;
const spawn = () => {
if (!alive) { return; }
const id = rockId.current + 1;
rockId.current = id;
const size = 20 + Math.floor(Math.random() * 11);
const fallMs = Math.max(820, 1500 - g.current.score * 14);
setRocks(rs => [...rs, { id, leftPct: 4 + Math.random() * 84, size, fallMs }]);
t = window.setTimeout(spawn, Math.max(320, 720 - g.current.score * 9));
};
t = window.setTimeout(spawn, 350);
return () => { alive = false; window.clearTimeout(t); };
}, [running]);
// 이동 + 충돌 검사 루프
useEffect(() => {
if (!running) { return; }
let raf = 0;
let last = performance.now();
const overlap = (a: DOMRect, b: DOMRect) =>
a.left < b.right - 2 && a.right > b.left + 2 && a.top < b.bottom - 4 && a.bottom > b.top + 4;
const tick = (now: number) => {
const dt = Math.min(64, now - last) / 1000;
last = now;
const field = fieldRef.current, player = playerRef.current;
if (!field || !player) { return; }
const dir = (keys.current.right ? 1 : 0) - (keys.current.left ? 1 : 0);
if (dir !== 0) {
pointerX.current = null;
px.current += dir * PLAYER_SPEED * dt;
} else if (pointerX.current !== null) {
const d = pointerX.current - px.current;
const step = PLAYER_SPEED * dt;
px.current += Math.abs(d) <= step ? d : Math.sign(d) * step;
}
place();
if (now >= invulnUntil.current) {
const pr = player.getBoundingClientRect();
const hit = new DOMRect(pr.left + 5, pr.top + 6, pr.width - 10, pr.height - 10);
const els = field.querySelectorAll("[data-rock]");
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (overlap(hit, el.getBoundingClientRect())) {
const id = Number(el.getAttribute("data-rock"));
removeRock(id);
damage(now);
break;
}
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running]);
// 게임 끝 오버레이가 열리면 다시 시작 버튼으로 포커스를 옮긴다
useEffect(() => {
if (over && restartRef.current) { restartRef.current.focus({ preventScroll: true }); }
}, [over]);
function onFieldPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
if (!g.current.running && !g.current.over) { startRun(); setPointerFrom(e); }
}
function onFieldPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (g.current.running) { setPointerFrom(e); }
}
const rootCls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const rockVars = (r: Rock): Vars => ({ "--t": `${r.fallMs}ms` });
const hearts = [0, 1, 2];
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-game" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2"/><path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/></g></symbol>
<symbol id="ico-trophy" 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="ico-restart" 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>
<symbol id="ico-heart" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></symbol>
<symbol id="ico-rocket" viewBox="0 0 256 256" fill="currentColor"><g><path d="m94.81 192l-29.45 22.24a8 8 0 0 1-12.81-4.51L40.19 154.1a8 8 0 0 1 1.66-6.86l30.31-36.33C71 134.25 76.7 161.43 94.81 192m119.34-44.76l-30.31-36.33c1.21 23.34-4.54 50.52-22.65 81.09l29.45 22.24a8 8 0 0 0 12.81-4.51l12.36-55.63a8 8 0 0 0-1.66-6.86" opacity=".2"/><path d="M152 224a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8m-24-112a12 12 0 1 0-12-12a12 12 0 0 0 12 12m95.62 43.83l-12.36 55.63a16 16 0 0 1-25.51 9.11L158.51 200h-61l-27.26 20.57a16 16 0 0 1-25.51-9.11l-12.36-55.63a16.09 16.09 0 0 1 3.32-13.71l28.56-34.26a123 123 0 0 1 8.57-36.67c12.9-32.34 36-52.63 45.37-59.85a16 16 0 0 1 19.6 0c9.34 7.22 32.47 27.51 45.37 59.85a123 123 0 0 1 8.57 36.67l28.56 34.26a16.09 16.09 0 0 1 3.32 13.71M99.43 184h57.14c21.12-37.54 25.07-73.48 11.74-106.88C156.55 47.64 134.49 29 128 24c-6.51 5-28.57 23.64-40.33 53.12c-13.31 33.4-9.36 69.34 11.76 106.88m-15 5.85q-16.15-29.35-19.6-57.69L48 152.36L60.36 208l.18-.13ZM208 152.36l-16.83-20.2q-3.42 28.28-19.56 57.69l23.85 18l.18.13Z"/></g></symbol>
</defs>
</svg>
<div className={rootCls} style={vars}>
<header className={styles.hud}>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-game" /></svg>
<span className={styles.label}>{LABEL_SCORE}</span>
<b className={styles.num} aria-live="polite">{score}</b>
</p>
<p className={styles.livesBox} aria-label={LIVES_ARIA.replace("{n}", String(lives))}>
{hearts.map(i => (
<i key={i} className={[styles.life, i < lives ? styles.lifeOn : ""].filter(Boolean).join(" ")}>
<svg aria-hidden="true"><use href="#ico-heart" /></svg>
</i>
))}
</p>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-trophy" /></svg>
<span className={styles.label}>{LABEL_BEST}</span>
<b className={styles.num}>{best}</b>
</p>
</header>
<div
className={styles.field}
ref={fieldRef}
tabIndex={0}
role="application"
aria-label={FIELD_ARIA}
onPointerDown={onFieldPointerDown}
onPointerMove={onFieldPointerMove}
>
{demo && (
<>
<i className={styles.rockDemo} style={demoVars("16%", "22%", "0ms")} />
<i className={styles.rockDemo} style={demoVars("40%", "34%", "-500ms")} />
<i className={styles.rockDemo} style={demoVars("62%", "58%", "-1000ms")} />
<i className={styles.rockDemo} style={demoVars("84%", "70%", "-1500ms")} />
</>
)}
{rocks.map(r => (
<i
key={r.id}
className={styles.rock}
data-rock={r.id}
style={{ left: `${r.leftPct}%`, width: r.size, height: r.size, ...rockVars(r) }}
onAnimationEnd={() => dodge(r.id)}
/>
))}
<span className={styles.player} ref={playerRef}>
<svg className={styles.rocket} aria-hidden="true"><use href="#ico-rocket" /></svg>
</span>
{!running && !over && <p className={styles.hint}>{HINT}</p>}
{over && (
<div className={styles.over}>
<div className={styles.overPanel}>
<p className={styles.overTitle}>{OVER_TITLE}</p>
<p className={styles.overScore}>{OVER_SCORE_TEXT.replace("{n}", String(score))}</p>
<button className={styles.restart} ref={restartRef} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-restart" /></svg>
<span>{RESTART}</span>
</button>
</div>
</div>
)}
</div>
</div>
</main>
);
}07 · 떨어지는 것 피하기 — 측정 누적면적 14.249% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이동은 프레임마다 낙하는 CSS 가
function tick(now) {
if (!running) { return; }
var dt = Math.min(64, now - lastT) / 1000;
lastT = now;
var dir = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
if (dir !== 0) {
pointerX = null;
px += dir * PLAYER_SPEED * dt;
} else if (pointerX !== null) {
var d = pointerX - px;
var step = PLAYER_SPEED * dt;
px += Math.abs(d) <= step ? d : Math.sign(d) * step;
}
placePlayer();
if (now >= invulnUntil) {
var pr = player.getBoundingClientRect();
var hit = { left: pr.left + 5, right: pr.right - 5, top: pr.top + 6, bottom: pr.bottom - 4 };
for (var i = rocks.length - 1; i >= 0; i--) {
if (rectsOverlap(hit, rocks[i].getBoundingClientRect())) { hitRock(rocks[i]); break; }
}
}
rafId = requestAnimationFrame(tick);
}
로켓만 requestAnimationFrame 으로 옮기고 장애물 낙하는 @keyframes dfFall 에 맡겨 두면 돌 열 개가 동시에 떨어져도 JS 가 세는 것은 로켓 좌표 하나뿐이고, 한 프레임이 64ms 를 넘으면 그 값으로 잘라 탭으로 돌아왔을 때 로켓이 순간이동하지 않습니다.
히트박스는 보이는 상자보다 작게
function rectsOverlap(a, b) {
return a.left < b.right - 2 && a.right > b.left + 2 && a.top < b.bottom - 4 && a.bottom > b.top + 4;
}
function hitRock(rock) {
rock.remove();
var i = rocks.indexOf(rock);
if (i >= 0) { rocks.splice(i, 1); }
lives -= 1;
paintLives();
invulnUntil = performance.now() + 900;
player.classList.remove('is-hit');
void player.offsetWidth; // 피격 깜빡임 애니메이션을 처음부터 다시 트는 재정렬 트릭
player.classList.add('is-hit');
if (lives <= 0) { gameOver(); }
}
사각형 네 변을 2~6px 안쪽으로 당겨 두어야 "스친 것 같은데 죽었다" 는 억울함이 사라지고, 맞은 뒤 900ms 무적이 붙어 같은 돌에 두 번 깎이지 않습니다.
08탑 쌓기 타이밍
좌우로 왕복하는 블록을 멈춰 쌓되 삐져나온 만큼 잘려 나가는, 타이밍으로 미는 프로모션이나 대기 화면 경쟁 점수판에 쓰는 판입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-cube" viewBox="0 0 256 256" fill="currentColor"><g><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.16a8 8 0 0 1-4.16-7V80.2a8 8 0 0 1 .7-3.27Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44L128 120L47.66 76ZM40 90l80 43.78v85.79l-80-43.75Zm96 129.57v-85.75L216 90v85.78Z"/></g></symbol>
<symbol id="ico-trophy" 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="ico-restart" 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="st is-demo" id="st">
<header class="st__hud">
<p class="st__stat">
<svg class="st__ico" aria-hidden="true"><use href="#ico-cube"/></svg>
<span class="st__label">높이</span><b class="st__num" id="stHeight" aria-live="polite">0</b>
</p>
<p class="st__stat">
<span class="st__label">콤보</span><b class="st__num" id="stCombo">0</b>
</p>
<p class="st__stat">
<svg class="st__ico" aria-hidden="true"><use href="#ico-trophy"/></svg>
<span class="st__label">최고기록</span><b class="st__num" id="stBest">0</b>
</p>
</header>
<div class="st__field" id="stField" tabindex="0" role="application" aria-label="탑 쌓기 놀이판. 스페이스나 클릭으로 움직이는 블록을 멈춰 쌓습니다">
<div class="st__tower" id="stTower">
<div class="st__row st__row--base" id="stBase"></div>
<div class="st__mover" id="stMover"></div>
<i class="st__chip" id="stChip" aria-hidden="true"></i>
<p class="st__flash" id="stFlash">완벽!</p>
</div>
<p class="st__hint" id="stHint">스페이스나 클릭으로 멈춰 쌓으세요</p>
<div class="st__over" id="stOver" hidden>
<div class="st__overPanel">
<p class="st__overTitle">게임 끝</p>
<p class="st__overScore"><b id="stOverHeight">0단 쌓았습니다</b></p>
<button class="st__restart" id="stRestart" type="button">
<svg class="st__ico" aria-hidden="true"><use href="#ico-restart"/></svg><span>다시 시작</span>
</button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 탑 쌓기 타이밍 — 좌우로 왕복하는 블록을 멈춰 쌓는다. 블록은 원소 하나를 transform: scaleX 로
// 줄여 폭을 표현한다(레이아웃 재계산 없음). 삐져나온 조각은 잘려 transition 으로 낙하하고, 4px 이내
// 일치는 콤보와 폭 원상복구 보너스. .is-demo 미리보기는 왕복 → 정지 → 잘린 조각 낙하 한 바퀴를
// CSS keyframes 로 재생한다. 미리보기를 뗀 정적 화면(기준 블록 + 대기 블록)도 완결된 상태다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
// 흰 무대 위 글자는 잉크 (blog-design 대비 표)
.st { display: flex; flex-direction: column; gap: $sp-2; width: 100%; max-width: 440px; }
.st__hud { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.st__hud > p { display: flex; align-items: center; gap: $sp-1; margin: 0; }
.st__ico { width: 15px; height: 15px; flex: 0 0 auto; color: $subject-ink; }
.st__label { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: $subject-ink; }
.st__num {
font-weight: 800; font-size: clamp(13px, 3.6vw, 17px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.st__field {
position: relative; overflow: hidden;
height: clamp(104px, 52vh, 180px);
border: 2px solid rgba($subject-ink, .18);
border-radius: $r-card;
background: rgba($subject-ink, .05);
touch-action: manipulation;
cursor: pointer;
}
.st__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 바닥선 — 탑이 서는 지면
.st__field::after {
content: ""; position: absolute; left: $sp-2; right: $sp-2; bottom: 10px; height: 2px;
border-radius: $r-pill; background: rgba($subject-ink, .22);
}
.st__tower { position: absolute; left: 50%; bottom: 12px; will-change: transform; }
// 칸·움직이는 블록·잘린 조각은 같은 규격: left -64px (기준 블록 128px 를 탑 중심에 정렬) +
// translateX(--lx) + scaleX(--sx). lx 는 기준 블록 왼쪽을 0 으로 둔 오프셋(JS BASE_W·HALF 참조).
.st__row, .st__mover, .st__chip {
position: absolute; left: -64px; bottom: var(--by, 0px);
width: 128px; height: 18px;
border-radius: $r-xs;
transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1));
transform-origin: left center; // 왼쪽 변을 기준으로 폭이 줄어든다 — 쌓임 정렬의 기준 변
will-change: transform;
}
.st__row { background: $color; }
.st__row--base { background: $subject-ink; }
.st__row.is-new {
animation-name: stLand;
animation-duration: 180ms;
animation-timing-function: $ease-pop;
}
@keyframes stLand {
0% { transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1)) scaleY(1.35); }
100% { transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1)) scaleY(1); }
}
.st__mover {
background: $color;
outline: 2px dashed rgba($subject-ink, .55);
outline-offset: 2px;
}
.st__mover[hidden] { display: none; }
.st__chip { background: rgba($subject-ink, .30); opacity: 0; pointer-events: none; }
// 잘린 조각의 낙하 — keyframes 대신 transition: 시작 상태(.is-drop)와 끝 상태(.is-fall)
// 사이를 브라우저가 보간한다. 이 데모의 세 번째 칩. reduce 에서는 즉시 사라진다.
.st__chip.is-drop {
opacity: 1;
transition: transform 620ms $ease-out, opacity 200ms linear 420ms;
}
.st__chip.is-fall {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(150px) rotate(28deg);
opacity: 0;
}
.st__flash {
position: absolute; left: 50%; bottom: var(--fy, 44px);
transform: translateX(-50%) translateY(10px) scale(.6);
margin: 0; padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-blue-deep; color: $subject-cream; // 파란 면에 크림 글자는 deep 토큰 (5.98:1)
font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4;
white-space: nowrap; opacity: 0; pointer-events: none;
}
.st__flash.is-show {
animation-name: stFlashPop;
animation-duration: 640ms;
animation-timing-function: $ease-pop;
animation-fill-mode: forwards;
}
@keyframes stFlashPop {
0% { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.6); }
30% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.05); }
70% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
100% { opacity: 0; transform: translateX(-50%) translateY(-8px) scale(1); }
}
.st__hint {
position: absolute; left: 50%; top: $sp-2; transform: translateX(-50%);
margin: 0; padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-ink; color: $stage-paper;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
white-space: nowrap;
}
.st__hint[hidden] { display: none; }
.st__over {
position: absolute; inset: 0;
display: grid; place-items: center;
background: rgba($stage-ink, .55);
}
.st__over[hidden] { display: none; }
.st__overPanel {
display: grid; justify-items: center; gap: $sp-2;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.st__overTitle {
margin: 0; font-weight: 800; font-size: clamp(14px, 4vw, 18px); line-height: 1.2; color: $subject-ink;
}
.st__overScore {
margin: 0;
font-weight: 800; font-size: clamp(15px, 4.4vw, 20px); line-height: 1.2;
color: $subject-orange-deep; // 크림 면 위 주황 글자는 진한 계열토큰만 통과 (5.04:1)
}
.st__restart {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-blue-deep; color: $subject-cream;
cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.6;
transition: transform $dur-quick $ease-pop;
}
.st__restart .st__ico { color: $subject-cream; }
.st__restart:hover { transform: translateY(-2px) scale(1.04); }
.st__restart:active { transform: translateY(0) scale(.97); }
.st__restart:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 미리보기(.is-demo): 왕복 → 정지 → 잘린 조각 낙하 한 바퀴 ───────
// 실제 왕복 범위(꼭대기 칸 lx -w-MARGIN 부터 +t.w+MARGIN, 첫 칸 기준 -148~148px)를 그대로
// 재현한다. 정지는 오른쪽으로 삐져나온 채(translateX 44px) — 겹친 84px(.656) 만 남고
// 잘린 44px(.344) 는 조각이 되어 떨어진다. 복귀 컷은 90% 안에서 끝난다.
.st.is-demo .st__mover {
animation-name: stSlide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes stSlide {
0% { transform: translateX(-148px) scaleX(1); }
32% { transform: translateX(44px) scaleX(1); }
38% { transform: translateX(44px) scaleX(.656); } // 멈춰 적척 — 겹친 폭만 남는다
84% { transform: translateX(44px) scaleX(.656); }
88% { transform: translateX(-148px) scaleX(1); } // 컷 복귀 (루프 시작값, 90% 안)
100% { transform: translateX(-148px) scaleX(1); }
}
// 잘린 조각: 정지 컷(38% = 760ms) 순간에 같은 자리에서 떨어지기 시작한다.
.st.is-demo .st__chip {
bottom: 22px; // 미리보기에서는 첫 칸 높이에서 떨어지는 것으로 보인다
animation-name: stChipDemoFall;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-delay: 760ms;
}
@keyframes stChipDemoFall {
0% { transform: translateX(var(--lx, 128px)) scaleX(var(--sx, .344)) translateY(0) rotate(0deg); opacity: 1; }
26% { transform: translateX(var(--lx, 128px)) scaleX(var(--sx, .344)) translateY(150px) rotate(28deg); opacity: 1; }
27% { transform: translateX(var(--lx, 128px)) scaleX(var(--sx, .344)) translateY(150px) rotate(28deg); opacity: 0; }
100% { transform: translateX(var(--lx, 128px)) scaleX(var(--sx, .344)) translateY(150px) rotate(28deg); opacity: 0; }
}
@media (max-width: 340px) {
.st { gap: $sp-1; }
.st__field { height: clamp(96px, 46vh, 140px); }
.st__row, .st__mover, .st__chip { height: 16px; }
.st__overPanel { padding: $sp-2 $sp-3; gap: $sp-1; }
}
// reduce 에서는 왕복·낙하·팝 연출을 끈다. 실제 게임은 JS 가 왕복 대신 매번 무작위 위치로
// 놓고 쌓임을 즉시 적용한다(index.html 참조). 잘린 조각도 transition 없이 즉시 사라진다.
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__mover, .st.is-demo .st__chip { animation: none !important; }
.st__row.is-new { animation: none !important; }
.st__chip.is-drop { transition: none !important; opacity: 0 !important; }
.st__flash.is-show { animation: none !important; opacity: 1 !important; }
.st__restart { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.st {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.st__hud {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.st__hud > p {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.st__ico {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: #17141a;
}
.st__label {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: #17141a;
}
.st__num {
font-weight: 800;
font-size: clamp(13px, 3.6vw, 17px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.st__field {
position: relative;
overflow: hidden;
height: clamp(104px, 52vh, 180px);
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
touch-action: manipulation;
cursor: pointer;
}
.st__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.st__field::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 10px;
height: 2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
}
.st__tower {
position: absolute;
left: 50%;
bottom: 12px;
will-change: transform;
}
.st__row, .st__mover, .st__chip {
position: absolute;
left: -64px;
bottom: var(--by, 0px);
width: 128px;
height: 18px;
border-radius: 4px;
transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1));
transform-origin: left center;
will-change: transform;
}
.st__row {
background: #2f6df6;
}
.st__row--base {
background: #17141a;
}
.st__row.is-new {
animation-name: stLand;
animation-duration: 180ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes stLand {
0% {
transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1)) scaleY(1.35);
}
100% {
transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1)) scaleY(1);
}
}
.st__mover {
background: #2f6df6;
outline: 2px dashed rgba(23, 20, 26, 0.55);
outline-offset: 2px;
}
.st__mover[hidden] {
display: none;
}
.st__chip {
background: rgba(23, 20, 26, 0.3);
opacity: 0;
pointer-events: none;
}
.st__chip.is-drop {
opacity: 1;
transition: transform 620ms ease-out, opacity 200ms linear 420ms;
}
.st__chip.is-fall {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(150px) rotate(28deg);
opacity: 0;
}
.st__flash {
position: absolute;
left: 50%;
bottom: var(--fy, 44px);
transform: translateX(-50%) translateY(10px) scale(0.6);
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
.st__flash.is-show {
animation-name: stFlashPop;
animation-duration: 640ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-fill-mode: forwards;
}
@keyframes stFlashPop {
0% {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.6);
}
30% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.05);
}
70% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-8px) scale(1);
}
}
.st__hint {
position: absolute;
left: 50%;
top: 8px;
transform: translateX(-50%);
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
white-space: nowrap;
}
.st__hint[hidden] {
display: none;
}
.st__over {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.55);
}
.st__over[hidden] {
display: none;
}
.st__overPanel {
display: grid;
justify-items: center;
gap: 8px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.st__overTitle {
margin: 0;
font-weight: 800;
font-size: clamp(14px, 4vw, 18px);
line-height: 1.2;
color: #17141a;
}
.st__overScore {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 20px);
line-height: 1.2;
color: #c93000;
}
.st__restart {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #0a4fe8;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.6;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.st__restart .st__ico {
color: #fff7e6;
}
.st__restart:hover {
transform: translateY(-2px) scale(1.04);
}
.st__restart:active {
transform: translateY(0) scale(0.97);
}
.st__restart:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.st.is-demo .st__mover {
animation-name: stSlide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes stSlide {
0% {
transform: translateX(-148px) scaleX(1);
}
32% {
transform: translateX(44px) scaleX(1);
}
38% {
transform: translateX(44px) scaleX(0.656);
}
84% {
transform: translateX(44px) scaleX(0.656);
}
88% {
transform: translateX(-148px) scaleX(1);
}
100% {
transform: translateX(-148px) scaleX(1);
}
}
.st.is-demo .st__chip {
bottom: 22px;
animation-name: stChipDemoFall;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 760ms;
}
@keyframes stChipDemoFall {
0% {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(0) rotate(0deg);
opacity: 1;
}
26% {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(150px) rotate(28deg);
opacity: 1;
}
27% {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(150px) rotate(28deg);
opacity: 0;
}
100% {
transform: translateX(var(--lx, 128px)) scaleX(var(--sx, 0.344)) translateY(150px) rotate(28deg);
opacity: 0;
}
}
@media (max-width: 340px) {
.st {
gap: 4px;
}
.st__field {
height: clamp(96px, 46vh, 140px);
}
.st__row, .st__mover, .st__chip {
height: 16px;
}
.st__overPanel {
padding: 8px 12px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.st.is-demo .st__mover, .st.is-demo .st__chip {
animation: none !important;
}
.st__row.is-new {
animation: none !important;
}
.st__chip.is-drop {
transition: none !important;
opacity: 0 !important;
}
.st__flash.is-show {
animation: none !important;
opacity: 1 !important;
}
.st__restart {
transition: none !important;
}
}// 탑 쌓기 타이밍 — 좌우로 왕복하는 블록(requestAnimationFrame)을 스페이스·클릭으로 멈춰 쌓는다.
// 아래 칸과 겹친 폭만 남고 삐져나온 부분은 잘려 떨어진다. 블록 폭은 원소 하나를 scaleX 로
// 줄여 표현한다(변하는 건 transform 뿐). 4px 이내 일치는 콤보 + 폭 원상복구 보너스.
// 쌓인 높이가 점수, 한 번도 안 겹치면 판이 끝난다.
var BEST_KEY = 'p301-08-best';
var OVER_HEIGHT_TEXT = '{n}단 쌓았습니다';
var PERFECT_PX = 4;
var BASE_W = 128; // 기준 블록 폭 (CSS .st__row width 와 같은 값)
var HALF = BASE_W / 2; // lx(왼쪽 오프셋)와 시각 좌표(탑 중심 0)를 잇는 보정값
var STRIDE = 22; // 칸 한 단의 세로 간격 (높이 18 + 틈 4)
var MARGIN = 20; // 꼭대기 칸 양옆으로 완전히 벗어나는 여유 — 이 구간에서 놓으면 미스
var root = document.getElementById('st');
var field = document.getElementById('stField');
var tower = document.getElementById('stTower');
var moverEl = document.getElementById('stMover');
var chipEl = document.getElementById('stChip');
var flashEl = document.getElementById('stFlash');
var heightEl = document.getElementById('stHeight');
var comboEl = document.getElementById('stCombo');
var bestEl = document.getElementById('stBest');
var hintEl = document.getElementById('stHint');
var overEl = document.getElementById('stOver');
var overHeightEl = document.getElementById('stOverHeight');
var restartBtn = document.getElementById('stRestart');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var rows = [{ lx: 0, w: BASE_W }]; // lx: 기준 블록 왼쪽을 0 으로 둔 상대 오프셋
var mover = { lx: -36, w: BASE_W, dir: 1, speed: 170 };
var combo = 0;
var height = 0;
var best = loadBest();
var running = false;
var rafId = 0;
var lastT = 0;
var chipTimer = 0;
function loadBest() {
try { return Number(localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; }
}
function saveBest(n) {
try { localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
// 왕복 범위는 꼭대기 칸 기준으로 양쪽 대칭: 상자가 꼭대기 칸과 MARGIN px 만큼 완전히 어긋난 양끝까지
// 왕복한다(시각 좌표로 왼쪽 끝 t.lx-HALF-w-MARGIN, 오른쪽 끝 t.lx-HALF+t.w+MARGIN). 그래서 첫 칸(완전 폭)도
// 양끝에서 겹침이 0 이 되고, 좌우 미스 구간의 폭은 언제나 MARGIN 으로 같다.
function minLx() { var t = topRow(); return t.lx - mover.w - MARGIN; }
function maxLx() { var t = topRow(); return t.lx + t.w + MARGIN; }
// window.top 은 브라우저가 못 바꾸는 전역 이름이라 top 이라고 지으면 스크립트 전체가 죽는다 — topRow 로
function topRow() { return rows[rows.length - 1]; }
function paintHud() {
heightEl.textContent = String(height);
comboEl.textContent = String(combo);
bestEl.textContent = String(best);
}
function setGeom(el, lx, w, by) {
el.style.setProperty('--lx', lx + 'px');
el.style.setProperty('--sx', String(w / BASE_W));
el.style.setProperty('--by', by + 'px');
}
function paintMover() { setGeom(moverEl, mover.lx, mover.w, rows.length * STRIDE); }
function addRowEl(row) {
var el = document.createElement('div');
el.className = 'st__row is-new';
setGeom(el, row.lx, row.w, (rows.length - 1) * STRIDE);
el.addEventListener('animationend', function () { el.classList.remove('is-new'); });
tower.appendChild(el);
}
function camera() {
// 탑 꼭대기가 놀이판 안에 보이게 탑 전체를 transform 으로 내린다 (레이아웃 재계산 없음)
var need = (rows.length - 1) * STRIDE + 18 + 64;
var shift = Math.max(0, need - field.clientHeight);
tower.style.transform = 'translateY(' + Math.round(shift) + 'px)';
}
function dropChip(lx, w, by) {
setGeom(chipEl, lx, w, by);
// 잘린 조각은 transition 으로 제자리에서 떨어진다: 시작 상태(is-drop)를 화면에 확정한 뒤
// 끝 상태(is-fall)를 얹으면 브라우저가 그 사이를 보간한다. reduce 에서는 transition 이 꺼져 즉시 사라진다.
chipEl.classList.remove('is-drop', 'is-fall');
void chipEl.offsetWidth; // transition 을 첫 프레임부터 트는 재정렬 트릭
chipEl.classList.add('is-drop');
void chipEl.offsetWidth;
chipEl.classList.add('is-fall');
clearTimeout(chipTimer);
chipTimer = setTimeout(function () { chipEl.classList.remove('is-drop', 'is-fall'); }, 720);
}
function showFlash() {
flashEl.style.setProperty('--fy', (rows.length * STRIDE + 6) + 'px');
flashEl.classList.remove('is-show');
void flashEl.offsetWidth;
flashEl.classList.add('is-show');
flashEl.addEventListener('animationend', function once() {
flashEl.classList.remove('is-show');
flashEl.removeEventListener('animationend', once);
});
}
function nextMover() {
var t = topRow();
mover.w = t.w;
mover.speed = Math.min(330, 170 + rows.length * 9);
mover.dir = rows.length % 2 === 0 ? 1 : -1;
mover.lx = mover.dir === 1 ? minLx() : maxLx();
paintMover();
}
function place() {
var t = topRow();
var tl = t.lx - HALF, tr = tl + t.w;
var ml = mover.lx - HALF, mr = ml + mover.w;
var ovL = Math.max(ml, tl), ovR = Math.min(mr, tr);
var ovW = ovR - ovL;
if (ovW <= 0) { missPlace(ml, mr); return; }
if (Math.abs(ml - tl) <= PERFECT_PX && Math.abs(mr - tr) <= PERFECT_PX) {
combo += 1;
// 완벽 일치 보너스: 폭을 기준 폭까지 되살리되, 칸은 기준 블록 범위 안에 머문다.
var c = t.lx - HALF + t.w / 2; // 꼭대기 칸 중심(시각 좌표)
var lo = Math.max(c - HALF, -HALF);
var hi = Math.min(c + HALF, HALF);
rows.push({ lx: Math.round(lo + HALF), w: Math.round(hi - lo) });
showFlash();
} else {
combo = 0;
rows.push({ lx: ovL + HALF, w: ovW });
if (ml < ovL) { dropChip(ml + HALF, ovL - ml, (rows.length - 1) * STRIDE); }
else if (mr > ovR) { dropChip(ovR + HALF, mr - ovR, (rows.length - 1) * STRIDE); }
}
addRowEl(rows[rows.length - 1]);
height = rows.length - 1;
if (height > best) { best = height; saveBest(best); }
paintHud();
camera();
nextMover();
}
function missPlace(ml, mr) {
dropChip(ml + HALF, mr - ml, rows.length * STRIDE);
running = false;
cancelAnimationFrame(rafId);
moverEl.hidden = true;
hintEl.hidden = true;
saveBest(best);
overHeightEl.textContent = OVER_HEIGHT_TEXT.replace('{n}', String(height));
overEl.hidden = false;
restartBtn.focus({ preventScroll: true });
}
function tick(now) {
if (!running) { return; }
var dt = Math.min(64, now - lastT) / 1000;
lastT = now;
mover.lx += mover.dir * mover.speed * dt;
if (mover.lx >= maxLx()) { mover.lx = maxLx(); mover.dir = -1; }
if (mover.lx <= minLx()) { mover.lx = minLx(); mover.dir = 1; }
paintMover();
rafId = requestAnimationFrame(tick);
}
function startRun() {
if (running) { return; }
running = true;
root.classList.remove('is-demo');
hintEl.hidden = true;
overEl.hidden = true;
moverEl.hidden = false;
nextMover();
if (!reduce) {
lastT = performance.now();
rafId = requestAnimationFrame(tick);
}
field.focus({ preventScroll: true });
}
function act() {
if (!running) { startRun(); return; }
if (reduce) {
// 왕복 대신 놓을 자리를 입력마다 무작위로 정한다(reduced-motion) — 다만 겹침이 절반 이상
// 보장되는 안쪽 범위만 쓴다. 실수로 판이 바로 끝나지 않게 하는 완화다.
var t = topRow();
mover.lx = t.lx + (Math.random() - 0.5) * t.w;
paintMover();
}
place();
}
restartBtn.addEventListener('click', function () {
var list = tower.querySelectorAll('.st__row');
for (var i = 0; i < list.length; i++) {
if (list[i].id !== 'stBase') { list[i].remove(); }
}
chipEl.classList.remove('is-drop', 'is-fall');
clearTimeout(chipTimer);
rows = [{ lx: 0, w: BASE_W }];
combo = 0;
height = 0;
paintHud();
tower.style.transform = 'translateY(0px)';
startRun();
});
window.addEventListener('keydown', function (e) {
if (e.key !== ' ' && e.code !== 'Space') { return; }
if (e.target === restartBtn) { return; } // 버튼 포커스 중에는 기본 클릭에 맡긴다
if (!overEl.hidden) { return; }
e.preventDefault();
act();
});
field.addEventListener('pointerdown', function (e) {
if (!overEl.hidden || overEl.contains(e.target)) { return; }
act();
});
paintHud();
paintMover();
camera();// StackTowerTiming — vanilla 판(run/301/demo/08-stack-tower-timing/index.html) 의 React 포트.
// 좌우 왕복은 requestAnimationFrame, 블록 폭은 transform: scaleX, 잘린 조각의 낙하는 transition.
// 왕복 범위는 꼭대기 칸 기준 양쪽 대칭(칸과 MARGIN px 만큼 완전히 어긋난 양끝) — 값·규칙은 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import styles from "./StackTowerTiming.module.scss";
export type StackTowerTimingProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Row = { id: number; lx: number; w: number };
type Chip = { id: number; lx: number; w: number; by: number };
type Vars = { [k: string]: string | undefined };
// ambient.d.ts 의 useRef 는 { current: T | null } 을 돌려주므로 값 보관용 참조는 null 없는 상자로 단언한다
type Box<T> = { current: T };
const BEST_KEY = "p301-08-best";
const OVER_HEIGHT_TEXT = "{n}단 쌓았습니다";
const PERFECT_TEXT = "완벽!";
const FIELD_ARIA = "탑 쌓기 놀이판. 스페이스나 클릭으로 움직이는 블록을 멈춰 쌓습니다";
const HINT = "스페이스나 클릭으로 멈춰 쌓으세요";
const LABEL_HEIGHT = "높이";
const LABEL_COMBO = "콤보";
const LABEL_BEST = "최고기록";
const OVER_TITLE = "게임 끝";
const RESTART = "다시 시작";
const BASE_W = 128; // 기준 블록 폭
const HALF = BASE_W / 2; // lx(왼쪽 오프셋)와 시각 좌표(탑 중심 0)를 잇는 보정값
const STRIDE = 22; // 칸 한 단의 세로 간격 (높이 18 + 틈 4)
const MARGIN = 20; // 꼭대기 칸 양옆으로 완전히 벗어나는 여유 — 이 구간에서 놓으면 미스
const PERFECT_PX = 4;
function loadBest(): number {
try { return Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { return 0; }
}
function saveBest(n: number) {
try { window.localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
export default function StackTowerTiming({
duration = 2000,
easing = "ease-out",
accentColor = "#2f6df6",
}: StackTowerTimingProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const towerRef = useRef<HTMLDivElement | null>(null);
const moverRef = useRef<HTMLDivElement | null>(null);
const restartRef = useRef<HTMLButtonElement | null>(null);
const rowsRef = useRef([{ id: 0, lx: 0, w: BASE_W }]) as Box<Row[]>;
const moverRef2 = useRef({ lx: -36, w: BASE_W, dir: 1, speed: 170 }) as Box<{ lx: number; w: number; dir: number; speed: number }>;
const rowId = useRef(0) as Box<number>;
const g = useRef({ combo: 0, height: 0, best: 0, running: false, over: false }) as Box<{
combo: number; height: number; best: number; running: boolean; over: boolean;
}>;
const [rows, setRows] = useState<Row[]>([{ id: 0, lx: 0, w: BASE_W }]);
const [height, setHeight] = useState(0);
const [combo, setCombo] = useState(0);
const [best, setBest] = useState(0);
const [over, setOver] = useState(false);
const [running, setRunning] = useState(false);
const [demo, setDemo] = useState(true);
const [chip, setChip] = useState<Chip | null>(null);
const [chipFall, setChipFall] = useState(false);
const [flashId, setFlashId] = useState(0);
const reduce =
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--accent": accentColor };
function topRow(): Row {
return rowsRef.current[rowsRef.current.length - 1];
}
function minLx(): number { return topRow().lx - moverRef2.current.w - MARGIN; }
function maxLx(): number { return topRow().lx + topRow().w + MARGIN; }
function paintMover() {
const el = moverRef.current;
if (!el) { return; }
el.style.setProperty("--lx", moverRef2.current.lx + "px");
el.style.setProperty("--sx", String(moverRef2.current.w / BASE_W));
el.style.setProperty("--by", rowsRef.current.length * STRIDE + "px");
}
function dropChip(lx: number, w: number, by: number) {
setChipFall(false);
setChip({ id: chip ? chip.id + 1 : 1, lx, w, by });
}
function camera() {
const field = fieldRef.current, tower = towerRef.current;
if (!field || !tower) { return; }
const need = (rowsRef.current.length - 1) * STRIDE + 18 + 64;
const shift = Math.max(0, need - field.clientHeight);
tower.style.transform = "translateY(" + Math.round(shift) + "px)";
}
function nextMover() {
const t = topRow();
const m = moverRef2.current;
m.w = t.w;
m.speed = Math.min(330, 170 + rowsRef.current.length * 9);
m.dir = rowsRef.current.length % 2 === 0 ? 1 : -1;
m.lx = m.dir === 1 ? minLx() : maxLx();
paintMover();
}
function endGame() {
g.current.running = false;
g.current.over = true;
setRunning(false);
setOver(true);
saveBest(g.current.best);
}
function place() {
const t = topRow();
const tl = t.lx - HALF, tr = tl + t.w;
const m = moverRef2.current;
const ml = m.lx - HALF, mr = ml + m.w;
const ovL = Math.max(ml, tl), ovR = Math.min(mr, tr);
const ovW = ovR - ovL;
if (ovW <= 0) { dropChip(ml + HALF, mr - ml, rowsRef.current.length * STRIDE); endGame(); return; }
let row: Row;
if (Math.abs(ml - tl) <= PERFECT_PX && Math.abs(mr - tr) <= PERFECT_PX) {
g.current.combo += 1;
setCombo(g.current.combo);
// 완벽 일치 보너스: 폭을 기준 폭까지 되살리되 칸은 기준 블록 범위 안에 머문다.
const c = t.lx - HALF + t.w / 2;
const lo = Math.max(c - HALF, -HALF);
const hi = Math.min(c + HALF, HALF);
row = { id: ++rowId.current, lx: Math.round(lo + HALF), w: Math.round(hi - lo) };
setFlashId(f => f + 1);
} else {
g.current.combo = 0;
setCombo(0);
row = { id: ++rowId.current, lx: ovL + HALF, w: ovW };
if (ml < ovL) { dropChip(ml + HALF, ovL - ml, rowsRef.current.length * STRIDE); }
else if (mr > ovR) { dropChip(ovR + HALF, mr - ovR, rowsRef.current.length * STRIDE); }
}
rowsRef.current = [...rowsRef.current, row];
setRows(rowsRef.current);
g.current.height = rowsRef.current.length - 1;
setHeight(g.current.height);
if (g.current.height > g.current.best) {
g.current.best = g.current.height;
setBest(g.current.best);
saveBest(g.current.best);
}
camera();
nextMover();
}
function startRun() {
if (g.current.running) { return; }
g.current.running = true;
g.current.over = false;
setDemo(false);
setOver(false);
setRunning(true);
nextMover();
const field = fieldRef.current;
if (field) { field.focus({ preventScroll: true }); }
}
function act() {
if (!g.current.running) { startRun(); return; }
if (reduce) {
// 왕복 대신 놓을 자리를 입력마다 무작위로 정한다(reduced-motion) — 겹침 절반 이상 보장 범위
const t = topRow();
moverRef2.current.lx = t.lx + (Math.random() - 0.5) * t.w;
paintMover();
}
place();
}
function restart() {
rowsRef.current = [{ id: 0, lx: 0, w: BASE_W }];
setRows(rowsRef.current);
g.current.combo = 0;
g.current.height = 0;
setCombo(0);
setHeight(0);
setChip(null);
setChipFall(false);
const tower = towerRef.current;
if (tower) { tower.style.transform = "translateY(0px)"; }
startRun();
}
// 최초 배치 + 키보드
useEffect(() => {
g.current.best = loadBest();
setBest(g.current.best);
paintMover();
camera();
const down = (e: KeyboardEvent) => {
if (e.key !== " " && e.code !== "Space") { return; }
if (document.activeElement === restartRef.current) { return; } // 버튼 포커스 중에는 기본 클릭에 맡긴다
if (g.current.over) { return; }
e.preventDefault();
act();
};
window.addEventListener("keydown", down);
return () => window.removeEventListener("keydown", down);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 왕복 루프 (reduced-motion 에서는 돌지 않는다)
useEffect(() => {
if (!running || reduce) { return; }
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const m = moverRef2.current;
const dt = Math.min(64, now - last) / 1000;
last = now;
m.lx += m.dir * m.speed * dt;
if (m.lx >= maxLx()) { m.lx = maxLx(); m.dir = -1; }
if (m.lx <= minLx()) { m.lx = minLx(); m.dir = 1; }
paintMover();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, reduce]);
// 잘린 조각: 시작 상태가 화면에 확정된 뒤 끝 상태를 얹어 transition 으로 낙하시킨다
useEffect(() => {
if (!chip) { return; }
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setChipFall(true));
});
const t = window.setTimeout(() => { setChip(null); setChipFall(false); }, 720);
return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); window.clearTimeout(t); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chip ? chip.id : 0]);
// 게임 끝 오버레이가 열리면 다시 시작 버튼으로 포커스를 옮긴다
useEffect(() => {
if (over && restartRef.current) { restartRef.current.focus({ preventScroll: true }); }
}, [over]);
const rootCls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const chipCls = [styles.chip, chip ? styles.chipDrop : "", chipFall ? styles.chipFall : ""]
.filter(Boolean).join(" ");
const chipVars: Vars = chip
? { "--lx": `${chip.lx}px`, "--sx": String(chip.w / BASE_W), "--by": `${chip.by}px` }
: {};
const rowVars = (r: Row, i: number): Vars => ({
"--lx": `${r.lx}px`, "--sx": String(r.w / BASE_W),
"--by": `${i * STRIDE}px`,
});
function onFieldPointerDown() {
if (g.current.over) { return; }
act();
}
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-cube" viewBox="0 0 256 256" fill="currentColor"><g><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.16a8 8 0 0 1-4.16-7V80.2a8 8 0 0 1 .7-3.27Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44L128 120L47.66 76ZM40 90l80 43.78v85.79l-80-43.75Zm96 129.57v-85.75L216 90v85.78Z"/></g></symbol>
<symbol id="ico-trophy" 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="ico-restart" 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>
<div className={rootCls} style={vars}>
<header className={styles.hud}>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-cube" /></svg>
<span className={styles.label}>{LABEL_HEIGHT}</span>
<b className={styles.num} aria-live="polite">{height}</b>
</p>
<p className={styles.stat}>
<span className={styles.label}>{LABEL_COMBO}</span>
<b className={styles.num}>{combo}</b>
</p>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-trophy" /></svg>
<span className={styles.label}>{LABEL_BEST}</span>
<b className={styles.num}>{best}</b>
</p>
</header>
<div
className={styles.field}
ref={fieldRef}
tabIndex={0}
role="application"
aria-label={FIELD_ARIA}
onPointerDown={onFieldPointerDown}
>
<div className={styles.tower} ref={towerRef}>
{rows.map((r, i) => (
<div
key={r.id}
className={[styles.row, r.id === 0 ? styles.rowBase : styles.rowNew].filter(Boolean).join(" ")}
style={rowVars(r, i)}
/>
))}
<div
className={[styles.mover, over ? styles.moverHidden : ""].filter(Boolean).join(" ")}
ref={moverRef}
/>
<i className={chipCls} style={chipVars} aria-hidden="true" />
<p key={flashId} className={[styles.flash, flashId ? styles.flashShow : ""].filter(Boolean).join(" ")}>
{PERFECT_TEXT}
</p>
</div>
{!running && !over && <p className={styles.hint}>{HINT}</p>}
{over && (
<div className={styles.over}>
<div className={styles.overPanel}>
<p className={styles.overTitle}>{OVER_TITLE}</p>
<p className={styles.overScore}>{OVER_HEIGHT_TEXT.replace("{n}", String(height))}</p>
<button className={styles.restart} ref={restartRef} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-restart" /></svg>
<span>{RESTART}</span>
</button>
</div>
</div>
)}
</div>
</div>
</main>
);
}08 · 탑 쌓기 타이밍 — 측정 누적면적 5.453% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
폭을 바꾸지 않고 폭을 줄이는 법
.st__tower { position: absolute; left: 50%; bottom: 12px; will-change: transform; }
// 칸·움직이는 블록·잘린 조각은 같은 규격: left -64px (기준 블록 128px 를 탑 중심에 정렬) +
// translateX(--lx) + scaleX(--sx). lx 는 기준 블록 왼쪽을 0 으로 둔 오프셋(JS BASE_W·HALF 참조).
.st__row, .st__mover, .st__chip {
position: absolute; left: -64px; bottom: var(--by, 0px);
width: 128px; height: 18px;
border-radius: $r-xs;
transform: translateX(var(--lx, 0px)) scaleX(var(--sx, 1));
transform-origin: left center; // 왼쪽 변을 기준으로 폭이 줄어든다 — 쌓임 정렬의 기준 변
will-change: transform;
}
폭을 width 로 줄이면 단이 쌓일 때마다 배치가 다시 계산되지만 scaleX 는 그리기만 바꾸므로, 아홉 개 중 애니메이션 수가 2개로 가장 적은데도 움직인 프레임은 13/23 이 나옵니다.
겹친 만큼만 남기는 세 값
function setGeom(el, lx, w, by) {
el.style.setProperty('--lx', lx + 'px');
el.style.setProperty('--sx', String(w / BASE_W));
el.style.setProperty('--by', by + 'px');
}
function paintMover() { setGeom(moverEl, mover.lx, mover.w, rows.length * STRIDE); }
function addRowEl(row) {
var el = document.createElement('div');
el.className = 'st__row is-new';
setGeom(el, row.lx, row.w, (rows.length - 1) * STRIDE);
el.addEventListener('animationend', function () { el.classList.remove('is-new'); });
tower.appendChild(el);
}
가로 위치·가로 배율·바닥 높이 세 개만 사용자 정의 속성으로 넘기면 새 단도 잘린 조각도 같은 규칙으로 그려지고, 위아래 두 단의 변이 4px 안쪽으로 맞으면 폭이 기준값 128px 로 되살아나는 보너스가 붙습니다.
09반응속도 재기
신호가 켜지는 순간 최대한 빨리 눌러 걸린 시간을 재는, 팀 페이지의 놀이나 반응속도 벤치마크 소재로 쓰는 판입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-lightning" viewBox="0 0 256 256" fill="currentColor"><g><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></symbol>
<symbol id="ico-clock" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></symbol>
<symbol id="ico-trophy" 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="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></symbol>
<symbol id="ico-restart" 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="rt is-demo is-idle" id="rt">
<header class="rt__hud">
<p class="rt__stat">
<svg class="rt__ico" aria-hidden="true"><use href="#ico-trophy"/></svg>
<span class="rt__label">최고기록</span><b class="rt__num" id="rtBest" aria-live="polite">—</b>
</p>
<p class="rt__stat">
<svg class="rt__ico" aria-hidden="true"><use href="#ico-clock"/></svg>
<span class="rt__label">직전 기록</span><b class="rt__num" id="rtLast">—</b>
</p>
</header>
<div class="rt__panel" id="rtPanel" tabindex="0" role="application" aria-label="반응속도 놀이판. 신호가 켜지면 클릭이나 스페이스로 최대한 빨리 누릅니다">
<p class="rt__face rt__face--wait">
<span class="rt__waitTitle" id="rtWaitTitle">클릭하면 시작합니다</span>
<span class="rt__waitSub" id="rtWaitSub">신호가 켜진 뒤 최대한 빨리 누르세요</span>
</p>
<p class="rt__face rt__face--go">
<svg class="rt__goIco" aria-hidden="true"><use href="#ico-lightning"/></svg>
<span class="rt__goTitle">지금 누르세요!</span>
</p>
<p class="rt__face rt__face--result">
<b class="rt__ms" id="rtMs">{n}ms</b>
<span class="rt__grade rt__grade--s3" id="rtGrade">보통</span>
<button class="rt__restart" type="button">
<svg class="rt__ico" aria-hidden="true"><use href="#ico-restart"/></svg><span>다시 시작</span>
</button>
</p>
<p class="rt__face rt__face--penalty">
<svg class="rt__penIco" aria-hidden="true"><use href="#ico-warning"/></svg>
<span class="rt__penTitle">너무 성급해요!</span>
<span class="rt__penSub">신호가 켜진 뒤에 누르세요</span>
<button class="rt__restart" type="button">
<svg class="rt__ico" aria-hidden="true"><use href="#ico-restart"/></svg><span>다시 시작</span>
</button>
</p>
<p class="rt__demoMs" id="rtDemoMs" aria-hidden="true"></p>
</div>
<p class="rt__hint">클릭 · 스페이스로 반응합니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 반응속도 재기 — 패널 하나가 대기(무작위 1.5~4초) → 신호(크림 면 전환, transition) → 결과(ms) 를 돈다.
// 신호가 켜진 순간부터 첫 클릭·Space 까지 performance.now() 로 잰다. 신호 전 입력은 성급 클릭 벌점.
// 상태 전환은 classList 로 상태 클래스를 갈아 끼우고, 패널 배경색만 transition 이 부드럽게 잇는다.
// .is-demo 미리보기는 대기 → 신호 컷(steps) → 가짜 ms 카운트업(@property 정수 + counter) 한 바퀴를
// 2초 주기로 돈다. 상태 교체에 반투명 크로스페이드는 쓰지 않는다(237·252 함정).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
// 미리보기 카운트업용 등록 정수 — CSS 만으로 ms 숫자를 센다(286 선례)
@property --dms {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
@include stage($stage-ink);
// 먹 무대 위 글자는 크림 (blog-design 대비 표)
.rt { display: flex; flex-direction: column; gap: $sp-2; width: 100%; max-width: 440px; }
.rt__hud { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.rt__hud > p { display: flex; align-items: center; gap: $sp-1; margin: 0; }
.rt__ico { width: 15px; height: 15px; flex: 0 0 auto; color: $subject-cream; }
.rt__label { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .72); }
.rt__num {
font-weight: 800; font-size: clamp(13px, 3.6vw, 17px); line-height: 1;
color: $subject-cream; font-variant-numeric: tabular-nums;
}
// ── 신호 패널 ─────────────────────────────────────────────
.rt__panel {
position: relative; overflow: hidden;
height: clamp(104px, 52vh, 180px);
border: 2px solid rgba($subject-cream, .16);
border-radius: $r-card;
background: $tick-panel;
// 상태별 배경 전환 — 대비가 큰 색끼리 transition 하나로 잇는다(신호만이 연출이다)
transition: background-color $dur-quick $easing;
touch-action: manipulation;
cursor: pointer;
}
.rt__panel:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rt.is-signal .rt__panel { background: $color; }
.rt.is-penalty .rt__panel { background: $error-soft; }
// 네 얼굴(대기·신호·결과·벌점)은 같은 자리에 겹쳐 두고 상태 클래스로 보인다
.rt__face {
position: absolute; inset: 0;
display: grid; place-content: center; justify-items: center; gap: $sp-1;
padding: $sp-2;
text-align: center;
opacity: 0; pointer-events: none;
}
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty { opacity: 1; }
.rt__waitTitle, .rt__goTitle, .rt__penTitle {
margin: 0; font-weight: 800; line-height: 1.2;
font-size: clamp(15px, 4.4vw, 21px);
}
.rt__waitSub, .rt__penSub {
margin: 0; font-weight: 700; line-height: 1.4;
font-size: clamp(10px, 2.6vw, 12px); color: rgba($subject-cream, .72);
}
.rt__face--wait, .rt__face--result { color: $subject-cream; } // 어두운 패널 위 크림 (15.2:1)
.rt__face--go { color: $subject-ink; } // 크림 면 위 잉크 (17.1:1)
.rt__face--penalty { color: $subject-ink; } // 연살구 면 위 잉크 (14.7:1)
.rt__goIco { width: 22px; height: 22px; color: $subject-ink; }
.rt__penIco { width: 22px; height: 22px; color: $error; } // 아이콘(비문자) — 3:1 UI 기준 통과
// 결과 얼굴 — '{n}ms' 큰 숫자 + 평가 라벨
.rt__ms {
margin: 0; font-weight: 800; line-height: 1;
font-size: clamp(30px, 9vw, 44px);
font-variant-numeric: tabular-nums;
}
// 다시 시작 버튼 — 결과·벌점 얼굴에 하나씩. 얼굴은 pointer-events:none 이라 버튼만 되살린다
.rt__restart {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-blue-deep; color: $subject-cream;
cursor: pointer; pointer-events: auto;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.6;
transition: transform $dur-quick $ease-pop;
}
.rt__restart .rt__ico { color: $subject-cream; }
.rt__restart:hover { transform: translateY(-2px) scale(1.04); }
.rt__restart:active { transform: translateY(0) scale(.97); }
.rt__restart:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 보이지 않는 얼굴의 버튼은 탭 순서에서도 빠진다
.rt__face { visibility: hidden; }
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty { visibility: visible; }
.rt.is-demo .rt__face { visibility: visible; }
.rt__grade {
margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 14px); line-height: 1.4;
}
.rt__grade--s1 { color: $tick-up; } // 매우 빠름 — 패널 위 9.15:1
.rt__grade--s2 { color: $tick-up; } // 빠름
.rt__grade--s3 { color: $subject-cream; } // 보통 — 15.2:1
.rt__grade--s4 { color: $tick-down; } // 느림 — 패널 위 6.31:1
.rt__hint {
margin: 0; text-align: center;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
color: rgba($subject-cream, .72);
}
// ── 미리보기(.is-demo): 대기 → 신호 컷 → 가짜 ms 카운트업 한 바퀴 ───────
// 배경·얼굴 교체는 steps(1, end) 컷 — 반투명 유령 프레임을 만들지 않는다. 카운트업은
// 등록 정수 --dms 를 0 에서 300 까지 세고 counter 로 화면에 찍는다. 복귀 컷은 90% 안(86%).
.rt.is-demo .rt__panel {
animation-name: rtPanelCut;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rtPanelCut {
0% { background-color: $tick-panel; }
32% { background-color: $color; } // 신호 컷
86% { background-color: $tick-panel; } // 복귀 컷 (90% 안)
100% { background-color: $tick-panel; }
}
.rt.is-demo .rt__face { animation-name: rtFaceCut; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.rt.is-demo .rt__face--go { animation-name: rtFaceGoCut; }
.rt.is-demo .rt__face--result,
.rt.is-demo .rt__face--penalty { animation-name: none; opacity: 0; }
@keyframes rtFaceCut {
0% { opacity: 1; }
32% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes rtFaceGoCut {
0% { opacity: 0; }
32% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
// 카운트업 숫자 — 미리보기에서만 보이는 가짜 ms. 신호 컷(32%)과 함께 올라 84% 에서 멈춘 뒤 복귀 컷에서 사라진다.
.rt__demoMs {
position: absolute; inset: 0;
display: grid; place-content: center; justify-items: center;
color: $subject-ink; // 크림 면 위 잉크
font-weight: 800; line-height: 1;
font-size: clamp(30px, 9vw, 44px);
font-variant-numeric: tabular-nums;
pointer-events: none;
opacity: 0;
counter-reset: dms var(--dms);
}
.rt__demoMs::after { content: counter(dms) "ms"; }
.rt.is-demo .rt__demoMs {
animation-name: rtFaceGoCut, rtCount;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
@keyframes rtCount {
0%, 32% { --dms: 0; }
84%, 100% { --dms: 300; }
}
// ── 인계 정비(2026-09-13) ───────────────────────────────────────────
// 1) 벌점 얼굴의 보조 글자가 공용 규칙의 크림(.72)을 물려받아 연살구 면 위에서 약 1.1:1 로
// 사실상 안 보였다 — 잉크 계열로 갈아 연살구(#ffe0d8) 위 6.5:1 (알파 하한 .72 준수).
.rt__penSub { color: rgba($subject-ink, .72); }
// 2) 미리보기에서 신호 얼굴(문구)과 카운트업 숫자가 같은 자리에 겹쳐 보였다 —
// 문구(32~52%) → 숫자 카운트업(52~84%) 순으로 이어지게 갈라 잡는다.
.rt.is-demo .rt__face--go { animation-name: rtGoTitleCut; }
@keyframes rtGoTitleCut {
0%, 32% { opacity: 0; }
35%, 49% { opacity: 1; }
52%, 100% { opacity: 0; }
}
.rt.is-demo .rt__demoMs { animation-name: rtDemoMsShow, rtCount; }
@keyframes rtDemoMsShow {
0%, 51% { opacity: 0; }
54%, 84% { opacity: 1; }
87%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.rt { gap: $sp-1; }
.rt__panel { height: clamp(96px, 46vh, 140px); }
.rt__ms { font-size: clamp(26px, 8vw, 34px); }
.rt__demoMs { font-size: clamp(26px, 8vw, 34px); }
}
// reduce 에서는 색 전환 transition 과 미리보기 연출을 끈다. 게임 자체는 즉시 전환으로 그대로
// 동작한다(index.html 참조 — 재는 건 performance.now() 라 연출과 무관하다).
@media (prefers-reduced-motion: reduce) {
.rt__panel, .rt__restart { transition: none !important; }
.rt.is-demo .rt__panel,
.rt.is-demo .rt__face,
.rt.is-demo .rt__demoMs { animation: none !important; }
.rt.is-demo .rt__demoMs { opacity: 0 !important; }
}@property --dms {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
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);
}
.rt {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.rt__hud {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rt__hud > p {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.rt__ico {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: #fff7e6;
}
.rt__label {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.rt__num {
font-weight: 800;
font-size: clamp(13px, 3.6vw, 17px);
line-height: 1;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.rt__panel {
position: relative;
overflow: hidden;
height: clamp(104px, 52vh, 180px);
border: 2px solid rgba(255, 247, 230, 0.16);
border-radius: 16px;
background: #231f28;
transition: background-color 220ms ease-out;
touch-action: manipulation;
cursor: pointer;
}
.rt__panel:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rt.is-signal .rt__panel {
background: #fff7e6;
}
.rt.is-penalty .rt__panel {
background: #ffe0d8;
}
.rt__face {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
padding: 8px;
text-align: center;
opacity: 0;
pointer-events: none;
}
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty {
opacity: 1;
}
.rt__waitTitle, .rt__goTitle, .rt__penTitle {
margin: 0;
font-weight: 800;
line-height: 1.2;
font-size: clamp(15px, 4.4vw, 21px);
}
.rt__waitSub, .rt__penSub {
margin: 0;
font-weight: 700;
line-height: 1.4;
font-size: clamp(10px, 2.6vw, 12px);
color: rgba(255, 247, 230, 0.72);
}
.rt__face--wait, .rt__face--result {
color: #fff7e6;
}
.rt__face--go {
color: #17141a;
}
.rt__face--penalty {
color: #17141a;
}
.rt__goIco {
width: 22px;
height: 22px;
color: #17141a;
}
.rt__penIco {
width: 22px;
height: 22px;
color: #c2361a;
}
.rt__ms {
margin: 0;
font-weight: 800;
line-height: 1;
font-size: clamp(30px, 9vw, 44px);
font-variant-numeric: tabular-nums;
}
.rt__restart {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #0a4fe8;
color: #fff7e6;
cursor: pointer;
pointer-events: auto;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.6;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rt__restart .rt__ico {
color: #fff7e6;
}
.rt__restart:hover {
transform: translateY(-2px) scale(1.04);
}
.rt__restart:active {
transform: translateY(0) scale(0.97);
}
.rt__restart:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rt__face {
visibility: hidden;
}
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty {
visibility: visible;
}
.rt.is-demo .rt__face {
visibility: visible;
}
.rt__grade {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.4;
}
.rt__grade--s1 {
color: #3ddc97;
}
.rt__grade--s2 {
color: #3ddc97;
}
.rt__grade--s3 {
color: #fff7e6;
}
.rt__grade--s4 {
color: #ff7a5e;
}
.rt__hint {
margin: 0;
text-align: center;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.72);
}
.rt.is-demo .rt__panel {
animation-name: rtPanelCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rtPanelCut {
0% {
background-color: #231f28;
}
32% {
background-color: #fff7e6;
}
86% {
background-color: #231f28;
}
100% {
background-color: #231f28;
}
}
.rt.is-demo .rt__face {
animation-name: rtFaceCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rt.is-demo .rt__face--go {
animation-name: rtFaceGoCut;
}
.rt.is-demo .rt__face--result,
.rt.is-demo .rt__face--penalty {
animation-name: none;
opacity: 0;
}
@keyframes rtFaceCut {
0% {
opacity: 1;
}
32% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rtFaceGoCut {
0% {
opacity: 0;
}
32% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rt__demoMs {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
color: #17141a;
font-weight: 800;
line-height: 1;
font-size: clamp(30px, 9vw, 44px);
font-variant-numeric: tabular-nums;
pointer-events: none;
opacity: 0;
counter-reset: dms var(--dms);
}
.rt__demoMs::after {
content: counter(dms) "ms";
}
.rt.is-demo .rt__demoMs {
animation-name: rtFaceGoCut, rtCount;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
@keyframes rtCount {
0%, 32% {
--dms: 0;
}
84%, 100% {
--dms: 300;
}
}
.rt__penSub {
color: rgba(23, 20, 26, 0.72);
}
.rt.is-demo .rt__face--go {
animation-name: rtGoTitleCut;
}
@keyframes rtGoTitleCut {
0%, 32% {
opacity: 0;
}
35%, 49% {
opacity: 1;
}
52%, 100% {
opacity: 0;
}
}
.rt.is-demo .rt__demoMs {
animation-name: rtDemoMsShow, rtCount;
}
@keyframes rtDemoMsShow {
0%, 51% {
opacity: 0;
}
54%, 84% {
opacity: 1;
}
87%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rt {
gap: 4px;
}
.rt__panel {
height: clamp(96px, 46vh, 140px);
}
.rt__ms {
font-size: clamp(26px, 8vw, 34px);
}
.rt__demoMs {
font-size: clamp(26px, 8vw, 34px);
}
}
@media (prefers-reduced-motion: reduce) {
.rt__panel, .rt__restart {
transition: none !important;
}
.rt.is-demo .rt__panel,
.rt.is-demo .rt__face,
.rt.is-demo .rt__demoMs {
animation: none !important;
}
.rt.is-demo .rt__demoMs {
opacity: 0 !important;
}
}// 반응속도 재기 — 대기(무작위 1.5~4초) → 신호(크림 면, transition) → 결과(ms) 상태 머신.
// 상태 전환은 classList 로 상태 클래스를 갈아 끼운다(is-idle/is-armed/is-signal/is-result/is-penalty).
// 신호가 켜진 순간(signalAt)부터 첫 클릭·Space 까지 performance.now() 로 잰다.
// 신호 전 입력은 성급 클릭 — 대기 타이머를 끄고 벌점 문구로 판을 초기화한다.
var BEST_KEY = 'p301-09-best';
var MS_TEXT = '{n}ms';
var IDLE_TITLE = '클릭하면 시작합니다';
var IDLE_SUB = '신호가 켜진 뒤 최대한 빨리 누르세요';
var ARM_TITLE = '신호를 기다리세요';
var ARM_SUB = '켜지면 바로 누르세요';
var NO_RECORD = '—';
// 평가 라벨 4단계 — 문구는 문구_09.json 이 로케일마다 바꾼다
var GRADES = [
{ max: 200, cls: 'rt__grade--s1', label: '매우 빠름' },
{ max: 260, cls: 'rt__grade--s2', label: '빠름' },
{ max: 350, cls: 'rt__grade--s3', label: '보통' },
{ max: Infinity, cls: 'rt__grade--s4', label: '느림' }
];
var WAIT_MIN = 1500; // 대기 최소 ms
var WAIT_RANGE = 2500; // 대기 폭 ms (1.5~4초)
var root = document.getElementById('rt');
var panel = document.getElementById('rtPanel');
var waitTitleEl = document.getElementById('rtWaitTitle');
var waitSubEl = document.getElementById('rtWaitSub');
var msEl = document.getElementById('rtMs');
var gradeEl = document.getElementById('rtGrade');
var bestEl = document.getElementById('rtBest');
var lastEl = document.getElementById('rtLast');
// 결과 얼굴과 벌점 얼굴에 같은 버튼이 하나씩 — 둘 다 처음 화면으로 되돌린다
var restartBtns = document.querySelectorAll('.rt__restart');
var state = 'idle';
var armTimer = 0;
var signalAt = 0;
var best = loadBest();
var last = null;
function loadBest() {
try {
var v = Number(localStorage.getItem(BEST_KEY));
return v > 0 ? v : null; // 최고기록은 최소 ms — 0 이하는 기록이 아니다
} catch (e) { return null; }
}
function saveBest() {
try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
function setState(next) {
state = next;
var map = { idle: 'is-idle', armed: 'is-armed', signal: 'is-signal', result: 'is-result', penalty: 'is-penalty' };
root.classList.remove('is-idle', 'is-armed', 'is-signal', 'is-result', 'is-penalty');
root.classList.add(map[next]);
}
function paintHud() {
bestEl.textContent = best === null ? NO_RECORD : best + 'ms';
lastEl.textContent = last === null ? NO_RECORD : last + 'ms';
}
function arm() {
root.classList.remove('is-demo'); // 격자 미리보기 연출을 떼고 실제 게임으로
waitTitleEl.textContent = ARM_TITLE;
waitSubEl.textContent = ARM_SUB;
setState('armed');
clearTimeout(armTimer);
armTimer = setTimeout(fire, WAIT_MIN + Math.random() * WAIT_RANGE);
}
function fire() {
signalAt = performance.now();
setState('signal');
}
function gradeFor(ms) {
for (var i = 0; i < GRADES.length; i++) { if (ms <= GRADES[i].max) { return GRADES[i]; } }
return GRADES[GRADES.length - 1];
}
function settle() {
var ms = Math.round(performance.now() - signalAt);
last = ms;
if (best === null || ms < best) { best = ms; saveBest(); }
var g = gradeFor(ms);
msEl.textContent = MS_TEXT.replace('{n}', String(ms));
gradeEl.textContent = g.label;
gradeEl.className = 'rt__grade ' + g.cls;
setState('result');
paintHud();
}
function penalize() {
clearTimeout(armTimer); // 판 초기화 — 걸려 있던 대기 타이머를 끈다
setState('penalty');
}
function act() {
if (state === 'armed') { penalize(); return; }
if (state === 'signal') { settle(); return; }
arm(); // idle · result · penalty 에서는 다음 판 시작
}
function isRestart(el) {
while (el && el !== panel) {
if (el.classList && el.classList.contains('rt__restart')) { return true; }
el = el.parentNode;
}
return false;
}
function resetIdle() {
clearTimeout(armTimer); // 판 초기화 — 걸려 있던 대기 타이머를 끄고 처음 화면으로
waitTitleEl.textContent = IDLE_TITLE;
waitSubEl.textContent = IDLE_SUB;
setState('idle');
paintHud();
}
panel.addEventListener('pointerdown', function (e) {
if (isRestart(e.target)) { return; } // 버튼 위 누름은 버튼의 click 에 맡긴다
act();
});
window.addEventListener('keydown', function (e) {
if (e.repeat) { return; } // 키 자동 반복으로 대기가 계속 새로 시작되지 않게 한다
if (e.key !== ' ' && e.code !== 'Space') { return; }
if (isRestart(e.target)) { return; } // 버튼 포커스 중에는 기본 클릭에 맡긴다
e.preventDefault();
act();
});
for (var bi = 0; bi < restartBtns.length; bi++) {
restartBtns[bi].addEventListener('click', function () { resetIdle(); });
}
setState('idle');
paintHud();// ReactionTimeSignal — vanilla 판(run/301/demo/09-reaction-time-signal/index.html) 의 React 포트.
// 대기(무작위 1.5~4초) → 신호(크림 면, transition) → 결과(ms) 상태 머신. 재는 건 performance.now().
// 상태 전환은 상태 클래스(isIdle/isArmed/isSignal/isResult/isPenalty) 갈아 끼우기로 화면에 반영한다.
import { useEffect, useRef, useState } from "react";
import styles from "./ReactionTimeSignal.module.scss";
export type ReactionTimeSignalProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Grade = { max: number; cls: string; label: string };
type Vars = { [k: string]: string | undefined };
// ambient.d.ts 의 useRef 는 { current: T | null } 을 돌려주므로 값 보관용 참조는 null 없는 상자로 단언한다
type Box<T> = { current: T };
const BEST_KEY = "p301-09-best";
const MS_TEXT = "{n}ms";
const IDLE_TITLE = "클릭하면 시작합니다";
const IDLE_SUB = "신호가 켜진 뒤 최대한 빨리 누르세요";
const ARM_TITLE = "신호를 기다리세요";
const ARM_SUB = "켜지면 바로 누르세요";
const GO_TITLE = "지금 누르세요!";
const PEN_TITLE = "너무 성급해요!";
const PEN_SUB = "신호가 켜진 뒤에 누르세요";
const LABEL_BEST = "최고기록";
const LABEL_LAST = "직전 기록";
const NO_RECORD = "—";
const RESTART = "다시 시작";
const HINT = "클릭 · 스페이스로 반응합니다";
const FIELD_ARIA = "반응속도 놀이판. 신호가 켜지면 클릭이나 스페이스로 최대한 빨리 누릅니다";
// 평가 라벨 4단계
const GRADES: Grade[] = [
{ max: 200, cls: "s1", label: "매우 빠름" },
{ max: 260, cls: "s2", label: "빠름" },
{ max: 350, cls: "s3", label: "보통" },
{ max: Infinity, cls: "s4", label: "느림" }
];
const GRADE_CLS: { [k: string]: string } = {
s1: "gradeS1", s2: "gradeS2", s3: "gradeS3", s4: "gradeS4"
};
const WAIT_MIN = 1500; // 대기 최소 ms
const WAIT_RANGE = 2500; // 대기 폭 ms (1.5~4초)
type Phase = "idle" | "armed" | "signal" | "result" | "penalty";
function loadBest(): number | null {
try {
const v = Number(window.localStorage.getItem(BEST_KEY));
return v > 0 ? v : null; // 최고기록은 최소 ms — 0 이하는 기록이 아니다
} catch (e) { return null; }
}
export default function ReactionTimeSignal({
duration = 2000,
easing = "ease-out",
accentColor = "#fff7e6",
}: ReactionTimeSignalProps) {
const st = useRef({ state: "idle" as Phase, best: null as number | null, last: null as number | null, signalAt: 0 }) as Box<{
state: Phase; best: number | null; last: number | null; signalAt: number;
}>;
const armTimer = useRef(0) as Box<number>;
const restartRef = useRef<HTMLButtonElement | null>(null); // 결과 얼굴의 버튼
const penRestartRef = useRef<HTMLButtonElement | null>(null); // 벌점 얼굴의 버튼
const [phase, setPhase] = useState<Phase>("idle");
const [best, setBest] = useState<number | null>(null);
const [last, setLast] = useState<number | null>(null);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--accent": accentColor };
function saveBest() {
try { window.localStorage.setItem(BEST_KEY, String(st.current.best)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
function go(next: Phase) {
st.current.state = next;
setPhase(next);
}
function arm() {
setDemo(false); // 격자 미리보기 연출을 떼고 실제 게임으로
window.clearTimeout(armTimer.current);
go("armed");
armTimer.current = window.setTimeout(() => {
st.current.signalAt = performance.now();
go("signal");
}, WAIT_MIN + Math.random() * WAIT_RANGE);
}
function settle() {
const ms = Math.round(performance.now() - st.current.signalAt);
st.current.last = ms;
setLast(ms);
if (st.current.best === null || ms < st.current.best) {
st.current.best = ms;
setBest(ms);
saveBest();
}
go("result");
}
function act() {
if (st.current.state === "armed") {
window.clearTimeout(armTimer.current); // 판 초기화 — 걸려 있던 대기 타이머를 끈다
go("penalty");
return;
}
if (st.current.state === "signal") { settle(); return; }
arm(); // idle · result · penalty 에서는 다음 판 시작
}
function restart() {
window.clearTimeout(armTimer.current); // 판 초기화 — 걸려 있던 대기 타이머를 끄고 처음 화면으로
go("idle");
}
// 최초 배치 + 키보드 + 언마운트 정리
useEffect(() => {
st.current.best = loadBest();
setBest(st.current.best);
const down = (e: KeyboardEvent) => {
if (e.repeat) { return; } // 키 자동 반복으로 대기가 계속 새로 시작되지 않게 한다
if (e.key !== " " && e.code !== "Space") { return; }
const ae = document.activeElement;
if (ae === restartRef.current || ae === penRestartRef.current) { return; } // 버튼 포커스 중에는 기본 클릭에 맡긴다
e.preventDefault();
act();
};
window.addEventListener("keydown", down);
return () => {
window.removeEventListener("keydown", down);
window.clearTimeout(armTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stateCls: { [k in Phase]: string } = {
idle: styles.isIdle, armed: styles.isArmed, signal: styles.isSignal, result: styles.isResult, penalty: styles.isPenalty
};
const rootCls = [styles.root, demo ? styles.isDemo : "", stateCls[phase]].filter(Boolean).join(" ");
const grade = last === null ? GRADES[2] : GRADES.find(g => last <= g.max) || GRADES[GRADES.length - 1];
const gradeCls = [styles.grade, styles[GRADE_CLS[grade.cls]]].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-lightning" viewBox="0 0 256 256" fill="currentColor"><g><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></symbol>
<symbol id="ico-clock" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></symbol>
<symbol id="ico-trophy" 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="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></symbol>
<symbol id="ico-restart" 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>
<div className={rootCls} style={vars}>
<header className={styles.hud}>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-trophy" /></svg>
<span className={styles.label}>{LABEL_BEST}</span>
<b className={styles.num} aria-live="polite">{best === null ? NO_RECORD : `${best}ms`}</b>
</p>
<p className={styles.stat}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-clock" /></svg>
<span className={styles.label}>{LABEL_LAST}</span>
<b className={styles.num}>{last === null ? NO_RECORD : `${last}ms`}</b>
</p>
</header>
<div
className={styles.panel}
tabIndex={0}
role="application"
aria-label={FIELD_ARIA}
onPointerDown={() => act()}
>
<p className={[styles.face, styles.faceWait].join(" ")}>
<span className={styles.waitTitle}>{phase === "idle" ? IDLE_TITLE : ARM_TITLE}</span>
<span className={styles.waitSub}>{phase === "idle" ? IDLE_SUB : ARM_SUB}</span>
</p>
<p className={[styles.face, styles.faceGo].join(" ")}>
<svg className={styles.goIco} aria-hidden="true"><use href="#ico-lightning" /></svg>
<span className={styles.goTitle}>{GO_TITLE}</span>
</p>
<p className={[styles.face, styles.faceResult].join(" ")}>
<b className={styles.ms}>{MS_TEXT.replace("{n}", String(last === null ? 0 : last))}</b>
<span className={gradeCls}>{grade.label}</span>
<button className={styles.restart} ref={restartRef} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-restart" /></svg>
<span>{RESTART}</span>
</button>
</p>
<p className={[styles.face, styles.facePenalty].join(" ")}>
<svg className={styles.penIco} aria-hidden="true"><use href="#ico-warning" /></svg>
<span className={styles.penTitle}>{PEN_TITLE}</span>
<span className={styles.penSub}>{PEN_SUB}</span>
<button className={styles.restart} ref={penRestartRef} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-restart" /></svg>
<span>{RESTART}</span>
</button>
</p>
<p className={styles.demoMs} aria-hidden="true" />
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}09 · 반응속도 재기 — 측정 누적면적 48.163% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
대기와 신호 사이를 가르는 상태기계
function arm() {
root.classList.remove('is-demo'); // 격자 미리보기 연출을 떼고 실제 게임으로
waitTitleEl.textContent = ARM_TITLE;
waitSubEl.textContent = ARM_SUB;
setState('armed');
clearTimeout(armTimer);
armTimer = setTimeout(fire, WAIT_MIN + Math.random() * WAIT_RANGE);
}
function fire() {
signalAt = performance.now();
setState('signal');
}
function act() {
if (state === 'armed') { penalize(); return; }
if (state === 'signal') { settle(); return; }
arm(); // idle · result · penalty 에서는 다음 판 시작
}
대기 시간이 1.5~4초 사이 난수라 외워서 누를 수 없고, 같은 입력이 상태에 따라 벌점·측정·다음 판 시작으로 갈리므로 버튼을 따로 만들 필요가 없습니다.
네 얼굴을 같은 자리에 겹쳐 두기
.rt__panel {
position: relative; overflow: hidden;
height: clamp(104px, 52vh, 180px);
border: 2px solid rgba($subject-cream, .16);
border-radius: $r-card;
background: $tick-panel;
// 상태별 배경 전환 — 대비가 큰 색끼리 transition 하나로 잇는다(신호만이 연출이다)
transition: background-color $dur-quick $easing;
touch-action: manipulation;
cursor: pointer;
}
.rt.is-signal .rt__panel { background: $color; }
.rt.is-penalty .rt__panel { background: $error-soft; }
.rt.is-idle .rt__face--wait,
.rt.is-armed .rt__face--wait,
.rt.is-signal .rt__face--go,
.rt.is-result .rt__face--result,
.rt.is-penalty .rt__face--penalty { opacity: 1; }
패널 전체가 한 번에 색을 갈아입는 판이라 면적 최대가 48.045%로 아홉 중 가장 크고, 대신 움직이는 순간이 신호 한 번뿐이라 움직인 프레임은 11/23 로 가장 적습니다.
css 연습 게임으로 쓰려면 무엇을 고정해야 하나?
아홉 개는 값 세 개만 공유하고 나머지는 항목마다 따로 두었기 때문에, 손댈 자리와 건드리면 안 되는 자리가 뚜렷하게 갈립니다.
바꿔도 아홉 개가 같이 따라오는 값
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| duration | $dur-loop 2s → 1s |
노트 낙하와 두더지 점멸이 두 배로 빨라져 판정 난이도가 올라간다 |
| easing | $ease-out → $ease-pop |
두더지와 패드의 튀어오름이 한 번 더 튀어 명중 순간이 눈에 박힌다 |
| color | $subject-blue → $stage-orange |
차분한 놀이판이 축제 분위기로 바뀌어 이벤트 페이지에 얹기 쉬워진다 |
짝이 맞아야만 도는 값
06 의 미로 문자표는 grid-template-areas 의 칸 이름과 같은 좌표계라 한쪽만 늘리면 벽이 엉뚱한 자리에 서므로, css grid 게임을 만들 생각으로 미로를 키운다면 두 곳을 같이 고쳐야 합니다. 08 은 JS 의 BASE_W 128 과 .st__row 의 width: 128px 가 같은 값일 때만 잘린 폭이 맞고, 05 는 한 박을 480ms 로 두고 판정 창 115·240·400ms 를 그 위에 얹었으니 박만 줄이면 창도 같이 줄여야 합니다.
어디서 깨지는가 — 함정
아홉 개를 다 만들고 나서 깨진 자리는 애니메이션이 아니라 입력과 타이머였습니다.
키를 누르고 있으면 한 판이 통째로 삼켜진다
키를 꾹 누르면 브라우저가 keydown 을 자동으로 되풀이해 보냅니다. 05 에서는 같은 레인의 다음 노트까지 연달아 판정되어 콤보가 혼자 올라갔고, 09 에서는 대기 상태에서 되풀이 입력이 들어와 성급 클릭 벌점이 무한히 찍혔습니다. 두 곳 모두 처리 맨 앞에 if (e.repeat) { return; } 한 줄을 두어 사람이 실제로 누른 첫 번째 입력만 받게 했습니다. 압축 파일 아홉 개의 비밀번호는 cj2ffvjv 이고, 여는 순간 이 한 줄이 05 와 09 의 keydown 처리 첫 줄에 그대로 있는 것을 볼 수 있습니다.
대기 타이머를 끄지 않으면 벌점 뒤에 신호가 켜진다
09 는 신호가 켜지기 전에 누르면 벌점인데, 이때 걸어 둔 대기 타이머가 그대로 살아 있으면 벌점 화면을 보고 있는 도중에 신호가 켜집니다. 그래서 penalize() 와 resetIdle() 둘 다 첫 줄이 clearTimeout(armTimer) 입니다.
top 이라는 이름 하나로 스크립트 전체가 죽는다
08 에서 꼭대기 칸을 돌려주는 함수를 top 이라고 지었더니 판이 아예 그려지지 않았습니다. window.top 은 브라우저가 예약해 둔 전역 이름이라 같은 이름의 함수 선언이 통째로 무시되고, 그다음 호출부터 전부 무너집니다. 이름을 topRow 로 바꾼 것이 수정의 전부였습니다.
미리보기 코드와 실제 코드가 갈라져 있다
격자 썸네일에서 움직이는 것은 .is-demo 가 붙었을 때만 도는 @keyframes 이고, 사람이 손댄 뒤의 움직임은 대부분 transition 입니다. 02 의 뒤집기 미리보기를 steps(1, end) 컷으로 만든 것도 3D 회전 중간의 모서리 각도가 대표 이미지로 잡히는 것을 막기 위해서였는데, 그 대가로 이 판의 누적면적이 1.367%까지 내려갔습니다. 한쪽만 살아 있어도 측정은 통과하므로 아홉 개 전부 .is-demo 를 뗀 상태에서 실제 입력으로 따로 눌러 봤습니다.
접근성
움직임을 줄여 달라는 설정에서도 게임 자체는 멈추지 않는 것이 이 편의 기준입니다.
reduced-motion 에서 끄는 것과 남기는 것
WCAG 2.2.2 정지·중지·숨김이 요구하는 것은 저절로 움직이는 연출을 멈추는 것이지 기능을 빼앗는 것이 아닙니다. 그래서 prefers-reduced-motion: reduce 에서는 아홉 개 모두 격자 미리보기 연출과 전환이 꺼지되 판정은 그대로 돕니다. 04 는 보간 계수를 1로 올려 눈이 즉시 따라오고, 05 는 노트를 판정선 자리에 점등식으로 세우며, 07 의 낙하는 steps(8, end) 단계 이동으로 바뀌고, 08 은 왕복 대신 입력마다 놓을 자리를 안쪽 범위에서 정합니다.
키보드와 보조기기에서 읽히는 자리
아홉 개 모두 다시 시작 버튼을 달았고, 01 은 켜진 패드로 포커스를 옮겨 스페이스만으로 끝까지 갈 수 있으며 06 은 방향키가 통로 칸으로 포커스를 옮깁니다. 07·08·09 의 놀이판은 role="application" 과 설명 문구를 붙여 화면 낭독기가 방향키를 게임에 넘기게 했고, 06 의 미로는 role="group", 01·02·03 의 결과 문구는 role="status", 05 의 판정 글자는 aria-live="polite" 로 알립니다.
FAQ
css 연습 게임으로 처음 열어 볼 항목은 어느 것인가요
01 이 가장 얕습니다. 형제 결합자와 counter-increment 만 읽으면 점수판까지 이해되고 JS 는 재시작과 포커스 이동뿐이라, 선택자 공부와 게임 제작이 같은 파일에서 끝납니다. 반대로 05·08·09 는 밀리초 계산이 있어 두 번째 주에 여는 편이 낫습니다.
게임 로직을 CSS 로만 짜도 실제 서비스에 올릴 수 있나요
점수를 되돌릴 필요가 없고 상태가 한 방향으로만 가는 놀이라면 올려도 됩니다. 01 이 그 경우인데, 체크박스 상태가 곧 진행도라서 새로 고치면 초기화되고 서버에 보낼 것도 없습니다. 순위표나 부정 방지가 필요해지는 순간부터는 상태를 JS 가 쥐어야 합니다.
모바일 터치에서도 아홉 개가 그대로 도나요
누르는 판인 01·02·03·07·08·09 는 터치에서 그대로 돕니다. 04 는 포인터를 끌어야 해서 방향키 조작을 같이 넣었고, 05·06 은 키 입력이 본체라 물리 키보드가 있을 때 제맛이 납니다. 09 의 패널에는 touch-action: manipulation 을 두어 연속 탭이 확대로 새지 않게 했습니다.









