04시선 피하기
화면의 눈이 커서를 계속 쫓아오고 겹치는 순간마다 벌점이 쌓이며, 눈을 피해 버틴 시간이 점수가 된다.
쓰임포인터 튜토리얼, 커서 인터랙션 포트폴리오
pointermovegetBoundingClientRecttranslate3d

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


