07落下物回避
地面のキャラクターを左右に動かして落下物を避ける。当たると残機が減り、0で終了する。
使いどころイベントランディングの遊びコーナー、反応速度のPR UI
keydowntranslateXrequestAnimationFrame

<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 ゲーム 作成 — CSSだけで動くミニゲーム9選
このデモが属する記事
CSS ゲーム 作成 — CSSだけで動くミニゲーム9選css ゲームとは、セレクタとキーフレームと transform だけでルールと画面の両方をまかなう仕掛けのことです。この9つはゲームエンジンなしで、素の HTML・CSS・バニラ JS だけで組んであり、下のグリッドでそのまま遊べます。 この記事のデモをすべて見る


