05패들 튕기기
패들이 마우스를 따라오고 공은 벽돌과 벽에서 법선 반사로 튕겨 점수를 쌓습니다. 패들은 pointermove 와 keydown 두 경로로 같은 세로 값을 공유합니다.
쓰임로딩 대신 하는 미니 게임, 404 페이지의 볼거리
requestAnimationFramepointermovekeydown

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-ball" 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 88"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="pb is-demo" id="pb">
<p class="pb__hint"><svg class="pb__ico" aria-hidden="true"><use href="#ico-ball"/></svg><span>패들이 마우스를 따라오고 공은 벽돌에 부딪힌 만큼 점수가 오릅니다</span></p>
<div class="pb__field" id="field" role="application" aria-label="벽돌 게임판" tabindex="0">
<p class="pb__score" aria-live="polite"><span class="pb__scoreVal" id="scoreText">점수 0</span><span class="pb__scoreLoop" aria-hidden="true"><i>점수 0</i><i>점수 1</i><i>점수 2</i></span></p>
<div class="pb__bricks" id="bricks" aria-hidden="true"></div>
<span class="pb__ball" id="ball" aria-hidden="true"></span>
<span class="pb__paddle" id="paddle" aria-hidden="true"></span>
</div>
<div class="pb__bar">
<button class="pb__launch" id="launch" type="button">
<svg class="pb__ico" aria-hidden="true"><use href="#ico-ball"/></svg><span>공 놓기</span>
</button>
<button class="pb__reset" id="reset" type="button">
<svg class="pb__ico" aria-hidden="true"><use href="#ico-reset"/></svg><span>처음으로</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 패들 튕기기 — 공은 속도 벡터를 법선으로 되돌리는 반사, 패들은 맞은 칸에 따라 수평 성분이 꺾인다.
// 패들은 pointermove(마우스) 와 keydown(방향키) 두 경로가 같은 px 값을 공유한다.
// 격자 미리보기(.is-demo)는 실제 게임의 왕복을 표본 추출한 공 경로 + 패들 추적 + 벽돌 섬광을 키프레임으로 재생한다.
// 루프의 시작과 끝을 패들 접촉점 한 곳에 두어 경계가 곧 패들 튕김이라 순간이동이 보이지 않는다.
$duration: $dur-loop;
$easing: linear; // 공은 등속 — 왕복 속도는 키프레임 간 거리 비율로 표본화했다
$color: $stage-yellow;
@include stage($stage-ink);
.pb { --kx: 1; --ky: 1; display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.pb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.pb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.pb__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .06);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
overflow: hidden; touch-action: none; cursor: none;
}
.pb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pb__bricks { position: absolute; inset: 0; }
.pb__brick {
position: absolute; left: 0; top: 0; width: 60px; height: 14px;
border-radius: $r-xs;
}
.pb__brick--r0 { background: $stage-orange; }
.pb__brick--r1 { background: $color; }
.pb__brick--r2 { background: $subject-cream; }
.pb__brick.is-hit { visibility: hidden; }
.pb__ball {
position: absolute; left: 0; top: 0; width: 12px; height: 12px;
border-radius: 50%; background: $subject-cream;
}
.pb__paddle {
position: absolute; left: 0; top: 0; width: 68px; height: 10px;
border-radius: $r-pill; background: $color;
}
.pb__score {
position: absolute; left: $sp-2; top: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); color: $subject-cream;
z-index: 1;
}
.pb__scoreLoop { position: absolute; inset: 0; display: grid; visibility: hidden; }
.pb__scoreLoop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
.pb__bar { display: flex; align-items: center; justify-content: flex-end; gap: $sp-2; flex-wrap: wrap; }
.pb__launch, .pb__reset {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.pb__launch .pb__ico, .pb__reset .pb__ico { color: $subject-ink; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 공 경로: 패들 접촉(0%) → 벽돌 3회 섬광 → 벽 2회 → 패들 접촉 2회 → 하강(94%) → 같은 접촉점(100%).
// 100% 와 0% 가 같은 점이라 경계가 닫혀 있고, 그 모서리는 패들 튕김으로 읽힌다.
.pb.is-demo .pb__ball {
animation-name: pbBall; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes pbBall {
0% { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px)); }
9% { transform: translate(calc(var(--kx) * 318px), calc(var(--ky) * 40px)); }
17% { transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 116px)); }
25% { transform: translate(calc(var(--kx) * 306px), calc(var(--ky) * 158px)); }
33% { transform: translate(calc(var(--kx) * 252px), calc(var(--ky) * 48px)); }
41% { transform: translate(calc(var(--kx) * 128px), calc(var(--ky) * 52px)); }
49% { transform: translate(calc(var(--kx) * 24px), calc(var(--ky) * 116px)); }
57% { transform: translate(calc(var(--kx) * 110px), calc(var(--ky) * 158px)); }
65% { transform: translate(calc(var(--kx) * 196px), calc(var(--ky) * 100px)); }
73% { transform: translate(calc(var(--kx) * 280px), calc(var(--ky) * 20px)); }
81% { transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 84px)); }
89% { transform: translate(calc(var(--kx) * 330px), calc(var(--ky) * 130px)); }
94% { transform: translate(calc(var(--kx) * 160px), calc(var(--ky) * 134px)); }
100% { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px)); }
}
// 패들 추적 — 공이 패들 선에 닿는 순간(0/25/57/96%) 의 x 로 맞춰 간다
.pb.is-demo .pb__paddle {
animation-name: pbPaddle; animation-duration: $duration; animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes pbPaddle {
0% { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)); }
9% { transform: translate(calc(var(--kx) * 284px), calc(var(--ky) * 158px)); }
25% { transform: translate(calc(var(--kx) * 272px), calc(var(--ky) * 158px)); }
41% { transform: translate(calc(var(--kx) * 94px), calc(var(--ky) * 158px)); }
57% { transform: translate(calc(var(--kx) * 76px), calc(var(--ky) * 158px)); }
73% { transform: translate(calc(var(--kx) * 246px), calc(var(--ky) * 158px)); }
89% { transform: translate(calc(var(--kx) * 296px), calc(var(--ky) * 158px)); }
96% { transform: translate(calc(var(--kx) * 136px), calc(var(--ky) * 158px)); }
100% { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)); }
}
// 벽돌 섬광 — 공이 닿는 9%(r1c4)·33%(r2c3)·41%(r2c1)·73%(r0c4) 에 반투명 스윕.
// 사라지지 않고 반짝이기만 해서 루프 경계에 되살아나는 순간이동이 없다.
.pb.is-demo .pb__brick--r1c4 { animation: pbFlash $duration linear infinite; animation-delay: 0ms; }
.pb.is-demo .pb__brick--r2c3 { animation: pbFlash $duration linear infinite; animation-delay: 460ms; }
.pb.is-demo .pb__brick--r2c1 { animation: pbFlash $duration linear infinite; animation-delay: 620ms; }
.pb.is-demo .pb__brick--r0c4 { animation: pbFlash $duration linear infinite; animation-delay: 1260ms; }
@keyframes pbFlash {
0%, 7%, 13%, 100% { opacity: 1; }
10% { opacity: .3; }
}
// 점수 교체 — 0→1(9%)→2(33%)→0(96%). steps 컷이라 반투명 겹침이 없다
.pb.is-demo .pb__scoreVal { color: transparent; }
.pb.is-demo .pb__scoreLoop { visibility: visible; }
.pb.is-demo .pb__scoreLoop i {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pb.is-demo .pb__scoreLoop i:nth-child(1) { animation-name: pbScore0; }
.pb.is-demo .pb__scoreLoop i:nth-child(2) { animation-name: pbScore1; }
.pb.is-demo .pb__scoreLoop i:nth-child(3) { animation-name: pbScore2; }
@keyframes pbScore0 { 0%, 8% { visibility: visible; } 9%, 95% { visibility: hidden; } 96%, 100% { visibility: visible; } }
@keyframes pbScore1 { 0%, 8% { visibility: hidden; } 9%, 32% { visibility: visible; } 33%, 100% { visibility: hidden; } }
@keyframes pbScore2 { 0%, 32% { visibility: hidden; } 33%, 95% { visibility: visible; } 96%, 100% { visibility: hidden; } }
@media (max-width: 340px) {
.pb { --kx: .68; --ky: .59; gap: $sp-2; }
.pb__field { height: 104px; }
.pb__brick { width: 40px; height: 10px; }
.pb__paddle { width: 52px; height: 8px; }
.pb__ball { width: 10px; height: 10px; }
}
// reduce 에서는 패들 위에 공이 얹힌 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__ball,
.pb.is-demo .pb__paddle,
.pb.is-demo .pb__brick,
.pb.is-demo .pb__scoreLoop i { animation: none !important; }
.pb.is-demo .pb__ball { transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 144px)) !important; }
.pb.is-demo .pb__paddle { transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)) !important; }
.pb.is-demo .pb__scoreVal { color: $subject-cream !important; }
.pb.is-demo .pb__scoreLoop { visibility: hidden !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);
}
.pb {
--kx: 1;
--ky: 1;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.pb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.pb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.pb__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
overflow: hidden;
touch-action: none;
cursor: none;
}
.pb__field:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pb__bricks {
position: absolute;
inset: 0;
}
.pb__brick {
position: absolute;
left: 0;
top: 0;
width: 60px;
height: 14px;
border-radius: 4px;
}
.pb__brick--r0 {
background: #ff4d1f;
}
.pb__brick--r1 {
background: #ffd23f;
}
.pb__brick--r2 {
background: #fff7e6;
}
.pb__brick.is-hit {
visibility: hidden;
}
.pb__ball {
position: absolute;
left: 0;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
}
.pb__paddle {
position: absolute;
left: 0;
top: 0;
width: 68px;
height: 10px;
border-radius: 999px;
background: #ffd23f;
}
.pb__score {
position: absolute;
left: 8px;
top: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
color: #fff7e6;
z-index: 1;
}
.pb__scoreLoop {
position: absolute;
inset: 0;
display: grid;
visibility: hidden;
}
.pb__scoreLoop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.pb__bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.pb__launch, .pb__reset {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.pb__launch .pb__ico, .pb__reset .pb__ico {
color: #17141a;
}
.pb.is-demo .pb__ball {
animation-name: pbBall;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes pbBall {
0% {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px));
}
9% {
transform: translate(calc(var(--kx) * 318px), calc(var(--ky) * 40px));
}
17% {
transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 116px));
}
25% {
transform: translate(calc(var(--kx) * 306px), calc(var(--ky) * 158px));
}
33% {
transform: translate(calc(var(--kx) * 252px), calc(var(--ky) * 48px));
}
41% {
transform: translate(calc(var(--kx) * 128px), calc(var(--ky) * 52px));
}
49% {
transform: translate(calc(var(--kx) * 24px), calc(var(--ky) * 116px));
}
57% {
transform: translate(calc(var(--kx) * 110px), calc(var(--ky) * 158px));
}
65% {
transform: translate(calc(var(--kx) * 196px), calc(var(--ky) * 100px));
}
73% {
transform: translate(calc(var(--kx) * 280px), calc(var(--ky) * 20px));
}
81% {
transform: translate(calc(var(--kx) * 398px), calc(var(--ky) * 84px));
}
89% {
transform: translate(calc(var(--kx) * 330px), calc(var(--ky) * 130px));
}
94% {
transform: translate(calc(var(--kx) * 160px), calc(var(--ky) * 134px));
}
100% {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 158px));
}
}
.pb.is-demo .pb__paddle {
animation-name: pbPaddle;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes pbPaddle {
0% {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px));
}
9% {
transform: translate(calc(var(--kx) * 284px), calc(var(--ky) * 158px));
}
25% {
transform: translate(calc(var(--kx) * 272px), calc(var(--ky) * 158px));
}
41% {
transform: translate(calc(var(--kx) * 94px), calc(var(--ky) * 158px));
}
57% {
transform: translate(calc(var(--kx) * 76px), calc(var(--ky) * 158px));
}
73% {
transform: translate(calc(var(--kx) * 246px), calc(var(--ky) * 158px));
}
89% {
transform: translate(calc(var(--kx) * 296px), calc(var(--ky) * 158px));
}
96% {
transform: translate(calc(var(--kx) * 136px), calc(var(--ky) * 158px));
}
100% {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px));
}
}
.pb.is-demo .pb__brick--r1c4 {
animation: pbFlash 2s linear infinite;
animation-delay: 0ms;
}
.pb.is-demo .pb__brick--r2c3 {
animation: pbFlash 2s linear infinite;
animation-delay: 460ms;
}
.pb.is-demo .pb__brick--r2c1 {
animation: pbFlash 2s linear infinite;
animation-delay: 620ms;
}
.pb.is-demo .pb__brick--r0c4 {
animation: pbFlash 2s linear infinite;
animation-delay: 1260ms;
}
@keyframes pbFlash {
0%, 7%, 13%, 100% {
opacity: 1;
}
10% {
opacity: 0.3;
}
}
.pb.is-demo .pb__scoreVal {
color: transparent;
}
.pb.is-demo .pb__scoreLoop {
visibility: visible;
}
.pb.is-demo .pb__scoreLoop i {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pb.is-demo .pb__scoreLoop i:nth-child(1) {
animation-name: pbScore0;
}
.pb.is-demo .pb__scoreLoop i:nth-child(2) {
animation-name: pbScore1;
}
.pb.is-demo .pb__scoreLoop i:nth-child(3) {
animation-name: pbScore2;
}
@keyframes pbScore0 {
0%, 8% {
visibility: visible;
}
9%, 95% {
visibility: hidden;
}
96%, 100% {
visibility: visible;
}
}
@keyframes pbScore1 {
0%, 8% {
visibility: hidden;
}
9%, 32% {
visibility: visible;
}
33%, 100% {
visibility: hidden;
}
}
@keyframes pbScore2 {
0%, 32% {
visibility: hidden;
}
33%, 95% {
visibility: visible;
}
96%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.pb {
--kx: .68;
--ky: .59;
gap: 8px;
}
.pb__field {
height: 104px;
}
.pb__brick {
width: 40px;
height: 10px;
}
.pb__paddle {
width: 52px;
height: 8px;
}
.pb__ball {
width: 10px;
height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pb.is-demo .pb__ball,
.pb.is-demo .pb__paddle,
.pb.is-demo .pb__brick,
.pb.is-demo .pb__scoreLoop i {
animation: none !important;
}
.pb.is-demo .pb__ball {
transform: translate(calc(var(--kx) * 210px), calc(var(--ky) * 144px)) !important;
}
.pb.is-demo .pb__paddle {
transform: translate(calc(var(--kx) * 176px), calc(var(--ky) * 158px)) !important;
}
.pb.is-demo .pb__scoreVal {
color: #fff7e6 !important;
}
.pb.is-demo .pb__scoreLoop {
visibility: hidden !important;
}
}var root = document.getElementById('pb');
var field = document.getElementById('field');
var bricksBox = document.getElementById('bricks');
var ball = document.getElementById('ball');
var paddle = document.getElementById('paddle');
var scoreText = document.getElementById('scoreText');
var SCORE_LABEL = '점수';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 벽돌 3×6 — 왼쪽 여백 22, 위 여백 24, 칸 60×14, 간격 4. 행 색은 CSS 클래스가 정한다.
// 격자의 원점은 여기 한 곳뿐이다 — 그리는 자리(laid)와 충돌 판정(step)이 같은 BX·BY 를 본다.
// 두 곳에 따로 적으면 위 여백만 옮기는 순간 공이 보이지 않는 띠에서 튕긴다
var COLS = 6, ROWS = 3, BX = 22, BY = 24, BW = 60, BH = 14, GAP = 4;
var bricks = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var el = document.createElement('i');
el.className = 'pb__brick pb__brick--r' + r + ' pb__brick--r' + r + 'c' + c;
el.style.transform = 'translate(0, 0)'; // 실제 위치는 laid() 가 판 폭에 맞춰 넣는다
bricksBox.appendChild(el);
bricks.push({ el: el, r: r, c: c, alive: true });
}
}
var PW = 68, PH = 10, SPEED = 240;
function padY() { return field.clientHeight - 18; }
var px = 176, bx = 210, by = 150, vx = SPEED * 0.66, vy = -SPEED * 0.75;
var stuck = true, score = 0, last = 0, running = false;
function scaled(n) { return n * field.clientWidth / 424; }
function laid() {
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
b.el.style.transform = 'translate(' + scaled(BX + b.c * (BW + GAP)) + 'px, ' + (BY + b.r * (BH + GAP)) + 'px)';
}
}
function paint() {
paddle.style.transform = 'translate(' + px + 'px, ' + padY() + 'px)';
ball.style.transform = 'translate(' + bx + 'px, ' + by + 'px)';
}
function paintScore() {
// 라벨을 낱말로 떼어 둬야 문구 사전(demo/문구.json)이 en·ja 로 바꿔 준다 — 붙여 쓴 '점수 ' 는 사전 키가 못 된다
scoreText.textContent = SCORE_LABEL + ' ' + score;
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var w = field.clientWidth, h = field.clientHeight;
if (stuck) { bx = px + PW / 2 - 6; by = padY() - 14; paint(); requestAnimationFrame(step); return; }
bx += vx * dt; by += vy * dt;
// 벽 반사 — 좌우와 천장
if (bx < 0) { bx = 0; vx = Math.abs(vx); }
if (bx > w - 12) { bx = w - 12; vx = -Math.abs(vx); }
if (by < 0) { by = 0; vy = Math.abs(vy); }
// 패들 반사 — 맞은 칸에 따라 수평 성분이 꺾인다 (법선 반사의 게임판 버전)
var pyNow = padY();
if (vy > 0 && by + 12 >= pyNow && by + 12 <= pyNow + PH && bx + 12 > px && bx < px + PW) {
var off = (bx + 6 - (px + PW / 2)) / (PW / 2);
vx = SPEED * off * 0.9;
vy = -Math.abs(vy);
by = pyNow - 12;
}
// 벽돌 충돌 — 겹친 칸을 끄고 뚫은 변으로 반사한다
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
if (!b.alive) { continue; }
var bxx = scaled(BX + b.c * (BW + GAP)), byy = BY + b.r * (BH + GAP);
if (bx + 12 > bxx && bx < bxx + scaled(BW) && by + 12 > byy && by < byy + BH) {
b.alive = false;
b.el.classList.add('is-hit');
score += 1; paintScore();
var overX = bx + 6 - (bxx + scaled(BW) / 2), overY = by + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vx = overX > 0 ? Math.abs(vx) : -Math.abs(vx); }
else { vy = overY > 0 ? Math.abs(vy) : -Math.abs(vy); }
break;
}
}
// 공을 놓쳤으면 패들 위에 다시 얹는다
if (by > h + 8) { stuck = true; bx = px + PW / 2 - 6; by = padY() - 14; }
paint();
requestAnimationFrame(step);
}
function wake() {
root.classList.remove('is-demo');
if (reduce.matches) {
px = 176; stuck = true; bx = px + PW / 2 - 6; by = padY() - 14;
paint(); paintScore(); return false;
}
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
return true;
}
function movePaddle(x) {
px = Math.max(0, Math.min(field.clientWidth - PW, x));
if (!running) { wake(); }
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
movePaddle(e.clientX - r.left - PW / 2);
});
field.addEventListener('keydown', function (e) {
var JUMP = 30;
if (e.key === 'ArrowLeft') { movePaddle(px - JUMP); }
else if (e.key === 'ArrowRight') { movePaddle(px + JUMP); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); launchBall(); }
else { return; }
e.preventDefault();
});
function launchBall() {
wake();
stuck = false;
vx = SPEED * 0.66; vy = -SPEED * 0.75;
}
document.getElementById('launch').addEventListener('click', launchBall);
document.getElementById('reset').addEventListener('click', function () {
for (var i = 0; i < bricks.length; i++) {
bricks[i].alive = true; bricks[i].el.classList.remove('is-hit');
}
score = 0; paintScore(); stuck = true; px = 176;
wake();
});
laid();
window.addEventListener('resize', laid);
paint(); paintScore();// PaddleBounce — vanilla 판(run/292/demo/05-physics-paddle-bounce/index.html) 의 React 포트.
// 공은 등속으로 날고 벽·패들·벽돌에 부딪히면 뚫은 변의 법선으로 속도 부호를 되돌린다.
// 패들은 맞은 칸(off) 에 따라 수평 성분이 SPEED×off×0.9 로 꺾인다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PaddleBounce.module.scss";
export type PaddleBounceProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type Brick = { el: HTMLElement; r: number; c: number; alive: boolean };
// 벽돌 3×6 — 왼쪽 여백 22, 위 여백 24, 칸 60×14, 간격 4. 행 색은 CSS 클래스가 정한다.
// 격자의 원점은 여기 한 곳뿐이다 — 그리는 자리(laid)와 충돌 판정(step)이 같은 BX·BY 를 본다.
// 두 곳에 따로 적으면 위 여백만 옮기는 순간 공이 보이지 않는 띠에서 튕긴다
const COLS = 6;
const ROWS = 3;
const BX = 22;
const BY = 24;
const BW = 60;
const BH = 14;
const GAP = 4;
const PW = 68;
const PH = 10;
const SPEED = 240;
const JUMP = 30; // 방향키 한 번에 옮기는 패들 거리
const FIELD_LABEL = "벽돌 게임판";
const SCORE_LABEL = "점수"; // 문구 사전이 en·ja 로 바꿔 줄 수 있게 상수 하나로 둔다
const CELLS: { r: number; c: number }[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) CELLS.push({ r: r, c: c });
}
export default function PaddleBounce({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: PaddleBounceProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const bricksBoxRef = useRef<HTMLDivElement | null>(null);
const ballRef = useRef<HTMLSpanElement | null>(null);
const paddleRef = useRef<HTMLSpanElement | null>(null);
const bricksRef = useRef<Brick[]>([]) as Box<Brick[]>;
const pxRef = useRef<number>(176) as Box<number>;
const bxRef = useRef<number>(210) as Box<number>;
const byRef = useRef<number>(150) as Box<number>;
const vxRef = useRef<number>(SPEED * 0.66) as Box<number>;
const vyRef = useRef<number>(-SPEED * 0.75) as Box<number>;
const stuckRef = useRef<boolean>(true) as Box<boolean>;
const scoreRef = useRef<number>(0) as Box<number>;
const lastRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [score, setScore] = useState(0);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function padY(): number {
const field = fieldRef.current;
return (field ? field.clientHeight : 176) - 18;
}
function scaled(n: number): number {
const field = fieldRef.current;
return field ? (n * field.clientWidth) / 424 : n;
}
function laid(): void {
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
const b = bricks[i];
b.el.style.transform = "translate(" + scaled(BX + b.c * (BW + GAP)) + "px, " + (BY + b.r * (BH + GAP)) + "px)";
}
}
function paint(): void {
const paddle = paddleRef.current;
const ball = ballRef.current;
if (paddle) paddle.style.transform = "translate(" + pxRef.current + "px, " + padY() + "px)";
if (ball) ball.style.transform = "translate(" + bxRef.current + "px, " + byRef.current + "px)";
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningRef.current = false; return; }
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const w = field.clientWidth;
const h = field.clientHeight;
if (stuckRef.current) {
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
paint();
rafRef.current = requestAnimationFrame(step);
return;
}
bxRef.current += vxRef.current * dt;
byRef.current += vyRef.current * dt;
// 벽 반사 — 좌우와 천장
if (bxRef.current < 0) { bxRef.current = 0; vxRef.current = Math.abs(vxRef.current); }
if (bxRef.current > w - 12) { bxRef.current = w - 12; vxRef.current = -Math.abs(vxRef.current); }
if (byRef.current < 0) { byRef.current = 0; vyRef.current = Math.abs(vyRef.current); }
// 패들 반사 — 맞은 칸에 따라 수평 성분이 꺾인다 (법선 반사의 게임판 버전)
const pyNow = padY();
if (
vyRef.current > 0 && byRef.current + 12 >= pyNow && byRef.current + 12 <= pyNow + PH &&
bxRef.current + 12 > pxRef.current && bxRef.current < pxRef.current + PW
) {
const off = (bxRef.current + 6 - (pxRef.current + PW / 2)) / (PW / 2);
vxRef.current = SPEED * off * 0.9;
vyRef.current = -Math.abs(vyRef.current);
byRef.current = pyNow - 12;
}
// 벽돌 충돌 — 겹친 칸을 끄고 뚫은 변으로 반사한다
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
const b = bricks[i];
if (!b.alive) continue;
const bxx = scaled(BX + b.c * (BW + GAP));
const byy = BY + b.r * (BH + GAP);
if (bxRef.current + 12 > bxx && bxRef.current < bxx + scaled(BW) && byRef.current + 12 > byy && byRef.current < byy + BH) {
b.alive = false;
b.el.classList.add(styles.isHit);
scoreRef.current += 1;
setScore(scoreRef.current);
const overX = bxRef.current + 6 - (bxx + scaled(BW) / 2);
const overY = byRef.current + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vxRef.current = overX > 0 ? Math.abs(vxRef.current) : -Math.abs(vxRef.current); }
else { vyRef.current = overY > 0 ? Math.abs(vyRef.current) : -Math.abs(vyRef.current); }
break;
}
}
// 공을 놓쳤으면 패들 위에 다시 얹는다
if (byRef.current > h + 8) {
stuckRef.current = true;
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
}
paint();
rafRef.current = requestAnimationFrame(step);
}
function wake(): void {
setDemo(false);
if (reduced()) {
pxRef.current = 176;
stuckRef.current = true;
bxRef.current = pxRef.current + PW / 2 - 6;
byRef.current = padY() - 14;
paint();
return;
}
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
function movePaddle(x: number): void {
const field = fieldRef.current;
if (!field) return;
pxRef.current = Math.max(0, Math.min(field.clientWidth - PW, x));
if (!runningRef.current) wake();
}
function launchBall(): void {
wake();
stuckRef.current = false;
vxRef.current = SPEED * 0.66;
vyRef.current = -SPEED * 0.75;
}
function restart(): void {
const bricks = bricksRef.current;
for (let i = 0; i < bricks.length; i++) {
bricks[i].alive = true;
bricks[i].el.classList.remove(styles.isHit);
}
scoreRef.current = 0;
setScore(0);
stuckRef.current = true;
pxRef.current = 176;
wake();
}
useEffect(() => {
const box = bricksBoxRef.current;
if (box) {
const nodes = box.querySelectorAll("i");
const built: Brick[] = [];
for (let n = 0; n < nodes.length; n++) {
built.push({ el: nodes[n], r: CELLS[n].r, c: CELLS[n].c, alive: true });
}
bricksRef.current = built;
}
laid();
paint();
function onResize(): void { laid(); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
movePaddle(event.clientX - r.left - PW / 2);
}
function onKeyDown(event: KeyEvt): void {
if (event.key === "ArrowLeft") movePaddle(pxRef.current - JUMP);
else if (event.key === "ArrowRight") movePaddle(pxRef.current + JUMP);
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); launchBall(); }
else return;
event.preventDefault();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
function brickCls(r: number, c: number): string {
const spot = styles["brickR" + r + "C" + c] || "";
return [styles.brick, styles["brickR" + r] || "", spot].filter(Boolean).join(" ");
}
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-ball" 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 88"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-ball" /></svg>
<span>패들이 마우스를 따라오고 공은 벽돌에 부딪힌 만큼 점수가 오릅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onKeyDown={onKeyDown}
>
<p className={styles.score} aria-live="polite">
<span className={styles.scoreVal}>{SCORE_LABEL} {score}</span>
<span className={styles.scoreLoop} aria-hidden="true"><i>점수 0</i><i>점수 1</i><i>점수 2</i></span>
</p>
<div className={styles.bricks} ref={bricksBoxRef} aria-hidden="true">
{CELLS.map((cell) => (
<i key={`${cell.r}-${cell.c}`} className={brickCls(cell.r, cell.c)} />
))}
</div>
<span className={styles.ball} ref={ballRef} aria-hidden="true"></span>
<span className={styles.paddle} ref={paddleRef} aria-hidden="true"></span>
</div>
<div className={styles.bar}>
<button className={styles.launch} type="button" onClick={launchBall}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-ball" /></svg><span>공 놓기</span>
</button>
<button className={styles.reset} type="button" onClick={restart}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-reset" /></svg><span>처음으로</span>
</button>
</div>
</div>
</main>
);
}05 패들 튕기기 — 측정 누적면적 6.159% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지
이 데모가 속한 글
물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지물리 시뮬레이션 만들기는 중력·스프링·마찰 같은 힘을 프레임마다 계산해 화면 요소의 자리를 직접 정하는 일입니다. 여기 실은 아홉 판은 라이브러리 없이 requestAnimationFrame 과 transform 두 이 글의 데모 전부 보기


