CSS ゲーム 作成 — CSSだけで動くミニゲーム9選
css ゲームとは、セレクタとキーフレームと transform だけでルールと画面の両方をまかなう仕掛けのことです。この9つはゲームエンジンなしで、素の 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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並びは人気順ではなく、勝ち負けの判定を誰が握っているかの順です。最初の3つは判定のほとんどが CSS の中にあり(01 はセレクタの連鎖、02 は反転トランジション、03 はせり上がりのトランジション)、真ん中の3つは JavaScript が座標とフォーカスを読んで CSS がそれを描き(04 カーソル追跡、05 アニメーションの時計、06 フォーカス移動)、最後の3つは JavaScript がフレームとタイムスタンプを自分で数えます(07 落下回避、08 往復の停止、09 経過ミリ秒)。「CSS だけ ゲーム」で検索して行き着くのはたいてい 01 のような形で、兄弟結合子ひとつが進行のすべてを背負っています。09 まで来ると、仕事はもうミリ秒の計算です。めくる動きそのものが目当てなら隣にカード フリップ アニメーション 9選【コピペ20行】があり、カーソルに吸い寄せられる場面ならマウス 追従 アニメーション 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
兄弟結合子で組んだ順番ロック
// 消えている(まだ押せない)パッドは 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; }
ここにはスコアを数える JavaScript が一行もありません。36個のアニメーションが重なるこの盤面は累積面積47.79%、動いたフレーム23/23 で、9つの中で最も動きの密度が高くなっています。
02神経衰弱カード
めくった2枚が同じなら表のまま残り、違えば裏へ戻ります。暇つぶしにもなり、表に問題を載せれば学習カードにもなる盤面です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1つの箱に表と裏を重ねる
.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 が後ろを向いた面を消すので、JavaScript に残る仕事はクラスの付け外しだけです。
一致なら固定、不一致は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);
}
2枚が開いている間の3クリック目は lock フラグが飲み込みます。プレビューの反転が回転ではなくカットなので、この盤面の累積面積は1.367%と、9つの中で最も低い数字になりました。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
せり上がりと引っ込みは 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 で組み直しています。穴ごとに animation-delay を負の値へずらしてあるので、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 後に出てくるので2匹が同時に立つことはありません。おかげでこの盤面は累積面積33.16%、平均強度129.4 と、9つの中で3番目に賑やかになっています。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
座標はプレイフィールドの左上を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 の2行が配置のすべてを担うので、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 とどまった時点で1つ入り、離れればゲージはその倍の速さで抜けていきます。
05リズムタップ
ノーツが3つのレーンを落ちてきて、判定ラインを通る瞬間に 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
判定の基準はアニメーション自身の時計
// 壁時計の値 — 曲の開始から流れた時刻を、そのノーツ自身の落下時間に換算する
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
迷路は文字5行
// 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 なので、壁が行く手を塞ぐというルールはどこにも書く必要がありません。迷路を作り替えたいときに触るのも、この5行だけです。
キャラクターを描くのは :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; }
すべてのマスに隠してあるキャラクターが、フォーカスを受けた1マスでだけ現れるので、JavaScript が動かすのはフォーカスだけになります。アウトラインが出るのはキーボードで入ったときだけで、これは :focus-visible を分けてあるおかげです。
07落下物回避
画面下のロケットを左右に動かして落下物をよけ、3回当たると終わりです。イベントページの遊びコーナーや、反応速度を売りにする 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
移動は毎フレーム、落下は 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 が運びます。画面に岩が10個あっても JavaScript が追う座標は1つで済み、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 の無敵を置いてあるので、1つの岩が残機を二重に削ることもありません。
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
幅を変えずに幅を縮める
.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 は描かれるものだけを変えます。だからこの盤面はアニメーションが9つの中で最少の2つしかないのに、動いたフレームは13/23 を記録しました。
重なった分だけを残す3つの値
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);
}
横のオフセット・横の倍率・階の高さがカスタムプロパティ3つで渡るので、新しい階も削れた欠片も同じ規則で描かれます。両端とも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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
待機と信号を分ける状態機械
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秒の乱数なので、覚えて先回りすることはできません。同じ入力が状態によってペナルティにも計測にも次の回の開始にもなるため、ボタンを分ける必要がなくなりました。
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%と9つの中でいちばん大きくなります。動く瞬間が信号そのものしかないぶん、動いたフレームは11/23 と最も少ない数字です。
css 練習 ゲームとして使うには何を固定すべきか?
9つが共有しているのは値3つだけで、あとは項目ごとに閉じてあります。だから触っていい場所と、そのままにしておく場所がはっきり分かれます。
9つが一斉についてくる値
| 名前 | 変えた値 | 感じ |
|---|---|---|
| duration | $dur-loop 2s → 1s |
ノーツの落下とモグラの点滅が倍の速さになり、判定の難度が上がる |
| easing | $ease-out → $ease-pop |
モグラとパッドの跳ね上がりがもう一度跳ね、命中の瞬間が目に焼き付く |
| color | $subject-blue → $stage-orange |
落ち着いた盤面が祭りの色に変わり、イベントページに載せやすくなる |
対でしか回らない値
CSS アニメーション ゲームが壊れるのはアニメーションの中ではなく、たいていこういう継ぎ目です。06 の迷路の文字表は grid-template-areas のマス名と同じ座標系にあるので、片方だけ伸ばすと壁が見当違いの場所に立ち、両方を一緒に育てることになります。08 は JS の BASE_W と .st__row の width: 128px が同じ数字でいる間だけ削り幅が合い、05 は1拍を480ms に置いて判定ウィンドウ115・240・400ms をその上に載せているので、拍を縮めるならウィンドウも一緒に縮めます。
どこで壊れるか — 落とし穴
9つを組み終えたあとに壊れていた場所は、アニメーションではなく入力とタイマーでした。
キーを押しっぱなしにすると1回分が丸ごと飲まれる
キーを押し続けるとブラウザが keydown を自動で繰り返し送ります。05 では同じレーンの次のノーツまで続けて判定されてコンボがひとりでに伸び、09 では待機中に繰り返しが届いてお手つきのペナルティが延々と表示されました。どちらも処理の先頭に if (e.repeat) { return; } の一行を置き、人が実際に押した最初の入力だけを通しています。9つの zip に共通のパスワードは 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%でした。片方だけ生きていても測定は通ってしまうので、9つとも .is-demo を外した状態で手で押して確かめています。
アクセシビリティ
動きを減らしてほしいという設定でも、ゲームそのものは取り上げません。それがこの記事の基準です。
reduced-motion で消すものと残すもの
WCAG 2.2.2 一時停止、停止、非表示が求めているのは、勝手に動く演出を止めることであって機能を奪うことではありません。そこで prefers-reduced-motion: reduce では9つともグリッドのプレビュー演出とトランジションが切れ、判定はそのまま動きます。04 は補間係数を1に上げて目が即座に追いつき、05 はノーツを判定ラインの位置に点灯式で立て、07 の落下は steps(8, end) の段階移動に変わり、08 は往復の代わりに入力ごとに置き場所を内側の範囲から決めます。
キーボードと支援技術が入る場所
9つとも再スタートの操作を備え、01 は点いたパッドへフォーカスを移すのでスペースキーだけで最後まで行けます。06 では矢印キーが通路のマスへフォーカスを移します。07・08・09 のプレイフィールドには role="application" と説明文を付けて、スクリーンリーダーが矢印キーをゲームへ渡すようにしました。06 の迷路は role="group"、01・02・03 の結果表示は role="status"、05 の判定文字は aria-live="polite" で読み上げます。
FAQ
css 練習 ゲームとして最初に開くならどれか
01 がいちばん浅いところにあります。兄弟結合子と counter-increment を読めばスコアボードまで理解でき、JavaScript は再スタートとフォーカス移動しか持たないので、セレクタの勉強とゲーム作りが同じファイルの中で終わります。逆に 05・08・09 はミリ秒の計算があるので、2周目に回すほうが気持ちよく読めます。
ロジックが CSS だけのゲームを本番のサービスに載せられますか
スコアを巻き戻す必要がなく、状態が一方向にしか進まない遊びなら載せられます。01 がその例で、チェックボックスの状態がそのまま進行度になり、再読み込みで初期化され、サーバーへ送るものもありません。順位表や不正対策が要る瞬間から、状態は JavaScript が握ることになります。
モバイルのタッチでも9つそのまま動きますか
押す盤面である 01・02・03・07・08・09 はタッチのままで遊べます。04 はポインターを引き回す必要があるので矢印キーの操作も用意し、05・06 はキー入力が本体なので物理キーボードがあると本領を発揮します。09 のパネルには touch-action: manipulation を置いて、連打が拡大に化けないようにしました。









