08탑 쌓기 타이밍
좌우로 왕복하는 블록을 멈춰 쌓는데 삐져나온 만큼 잘리고, 쌓인 높이가 점수가 되며 한 번 어긋나면 끝난다.
쓰임타이밍 이벤트 프로모션, 대기 화면 경쟁 점수판
requestAnimationFramescaleXtransition

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


