06포커스 미로 탈출
방향키로 칸을 옮겨 출구를 찾는 미로에서 캐릭터가 :focus 인 칸에 그려지고, 벽은 애초에 포커스를 받지 않아 막힌다.
쓰임키보드 접근성 데모, 탭 이동 가르치는 온보딩
keydown:focus-visiblegrid-template-areas

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="fme-pad-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2"/><path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/></g></symbol>
<symbol id="fme-flag-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2"/><path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87"/></g></symbol>
<symbol id="fme-trophy-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M200 48v63.1c0 39.7-31.75 72.6-71.45 72.9A72 72 0 0 1 56 112V48Z" opacity=".2"/><path d="M232 64h-24V48a8 8 0 0 0-8-8H56a8 8 0 0 0-8 8v16H24A16 16 0 0 0 8 80v16a40 40 0 0 0 40 40h3.65A80.13 80.13 0 0 0 120 191.61V216H96a8 8 0 0 0 0 16h64a8 8 0 0 0 0-16h-24v-24.42c31.94-3.23 58.44-25.64 68.08-55.58H208a40 40 0 0 0 40-40V80a16 16 0 0 0-16-16M48 120a24 24 0 0 1-24-24V80h24v32q0 4 .39 8Zm144-8.9c0 35.52-29 64.64-64 64.9a64 64 0 0 1-64-64V56h128ZM232 96a24 24 0 0 1-24 24h-.5a82 82 0 0 0 .5-8.9V80h24Z"/></g></symbol>
<symbol id="fme-restart-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></symbol>
<symbol id="fme-char-ico" viewBox="0 0 256 256" fill="currentColor"><g><path d="M176 48a24 24 0 1 1-24-24a24 24 0 0 1 24 24" opacity=".2"/><path d="M152 80a32 32 0 1 0-32-32a32 32 0 0 0 32 32m0-48a16 16 0 1 1-16 16a16 16 0 0 1 16-16m64 112a8 8 0 0 1-8 8c-35.31 0-52.95-17.81-67.12-32.12c-2.74-2.77-5.36-5.4-8-7.84l-13.43 30.88l37.2 26.57A8 8 0 0 1 160 176v56a8 8 0 0 1-16 0v-51.88l-31.07-22.2l-33.59 77.27A8 8 0 0 1 72 240a7.8 7.8 0 0 1-3.19-.67a8 8 0 0 1-4.15-10.52l54.08-124.37c-9.31-1.65-20.92 1.2-34.7 8.58a164 164 0 0 0-30.57 21.77a8 8 0 0 1-10.95-11.66c2.5-2.35 61.69-57.22 98.72-25.08c3.83 3.32 7.48 7 11 10.58C166.19 122.7 179.36 136 208 136a8 8 0 0 1 8 8"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="fme is-demo" id="fme">
<header class="fme__bar">
<p class="fme__title">
<svg class="fme__ico" aria-hidden="true"><use href="#fme-pad-ico"/></svg>
<span>포커스 미로 탈출</span>
</p>
<p class="fme__pill"><span class="fme__label">걸음</span><b class="fme__num" id="stepNum">0</b></p>
<p class="fme__pill fme__pill--best">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-trophy-ico"/></svg>
<b class="fme__num" id="bestNum">-</b>
</p>
<button class="fme__btn" id="restartBtn" type="button" aria-label="다시 시작">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-restart-ico"/></svg>
</button>
</header>
<div class="fme__mazeWrap">
<div class="fme__maze" id="maze" role="group" aria-label="7×5 미로. 방향키로 움직이세요"></div>
<div class="fme__done" id="done" hidden>
<div class="fme__donePanel">
<p class="fme__doneTitle">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-flag-ico"/></svg>
<span>탈출!</span>
</p>
<p class="fme__doneLine" id="doneLine">0 걸음</p>
<button class="fme__again" id="againBtn" type="button">
<svg class="fme__ico fme__ico--sm" aria-hidden="true"><use href="#fme-restart-ico"/></svg>
<span>다시 시작</span>
</button>
</div>
</div>
</div>
<p class="fme__hint">방향키로 칸을 옮겨 깃발까지 가세요 · 벽은 포커스를 받지 않아 저절로 막힙니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포커스 미로 탈출 — 7×5 칸을 grid-template-areas 로 배치한다. 통로는 button, 벽은 div 라
// 벽이 포커스를 못 받는 것이 곧 "막힌다" 는 규칙이다(막는 코드를 따로 쓰지 않는다).
// keydown 이 인접 통로 칸으로 focus({preventScroll:true}) 를 옮기고, 캐릭터는 :focus 인 칸에 나타난다.
// 미리보기(.is-demo)는 타이머가 정답 경로로 포커스를 옮겨 캐릭터가 미로를 걷는 연출이다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.fme {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 440px;
}
// ── 스코어보드 ──────────────────────────────────────────────
.fme__bar { display: flex; align-items: center; gap: $sp-1; flex-wrap: nowrap; }
.fme__title {
display: flex; align-items: center; gap: $sp-1; margin: 0; margin-right: auto;
font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.2; color: $subject-ink;
}
.fme__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.fme__ico--sm { width: 12px; height: 12px; }
.fme__pill {
display: flex; align-items: center; gap: $sp-1; margin: 0;
border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($subject-ink, .14);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2; color: $subject-ink;
white-space: nowrap;
}
.fme__label { font-weight: 600; color: rgba($subject-ink, .78); }
.fme__num { font-weight: 800; }
.fme__btn {
display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto;
border: 0; border-radius: $r-xs; padding: 0; background: rgba($subject-ink, .14); cursor: pointer;
color: $subject-ink;
}
.fme__btn:hover { background: rgba($subject-ink, .24); }
.fme__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 0; }
// ── 미로 ────────────────────────────────────────────────────
.fme__mazeWrap { position: relative; }
// 7열 5행 — 칸 이름은 index.html 의 MAZE 표와 같은 좌표계다(c<열>r<행>)
// 칸은 고정 폭 격자다 — 1fr 로 늘리면 5행 높이가 무대(300px)를 넘는다
.fme__maze {
display: grid; gap: $sp-1; justify-content: center;
grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
grid-template-areas:
"c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0"
"c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1"
"c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2"
"c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3"
"c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
border-radius: $r-card; padding: $sp-1; background: rgba($subject-ink, .12);
justify-self: center;
}
.fme__cell, .fme__wall {
aspect-ratio: 1 / 1; min-width: 0; border-radius: $r-xs;
}
.fme__wall { background: $subject-ink; }
.fme__cell {
display: grid; place-items: center; width: 100%;
border: 0; padding: 0; background: $subject-cream; cursor: pointer; color: $color;
}
.fme__cell.is-start { background: rgba($subject-cream, .6); }
// 출구는 깃발 자리 — 잉크 글자를 얹지 않으니 파란 면을 그대로 쓴다
.fme__cell.is-goal { background: $color; }
// 키보드로 짚은 칸만 테두리가 선다 — 마우스 클릭으로는 뜨지 않는다
.fme__cell:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
// 캐릭터 — 포커스를 받은 칸에서만 보인다. 벽은 button 이 아니라 포커스를 못 받으므로 갈 수 없다
.fme__char { width: 68%; height: 68%; opacity: 0; transform: scale(.6); color: $color; }
.fme__cell:focus .fme__char {
opacity: 1; transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.fme__cell.is-goal:focus .fme__char { color: $subject-cream; }
// 발자국 자국 — 포커스 칸 뒤로 은은한 원이 남아 지금 어디에 서 있는지 한눈에 보인다
.fme__cell::after {
content: ""; position: absolute; width: 100%; height: 100%; border-radius: $r-xs;
box-shadow: inset 0 0 0 2px rgba($color, .5); opacity: 0;
}
.fme__cell { position: relative; }
.fme__cell:focus::after { opacity: 1; }
// ── 완료 덮개 ───────────────────────────────────────────────
// display 를 직접 주는 규칙은 hidden 속성(UA display:none)을 이겨버린다 — 덮개가 사라지지 않는다
.fme [hidden] { display: none !important; }
.fme__done { position: absolute; inset: 0; display: grid; place-items: center; background: rgba($subject-ink, .55); }
.fme__donePanel {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
border-radius: $r-card; padding: $sp-4; background: $stage-paper; box-shadow: $shadow-raised;
}
.fme__doneTitle {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.2; color: $subject-ink;
}
.fme__doneLine { margin: 0; font-weight: 600; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.4; color: rgba($subject-ink, .78); }
.fme__again {
display: inline-flex; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-control; padding: $sp-1 $sp-3; background: $color;
color: $subject-cream; font-family: inherit; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4; cursor: pointer;
}
.fme__again:hover { background: $subject-blue; }
.fme__again:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.fme__hint {
margin: 0; font-weight: 500; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4;
color: rgba($subject-ink, .78);
}
// ── 격자 미리보기 ───────────────────────────────────────────
// 걷는 연출은 JS 타이머가 포커스를 옮겨 만든다(스크롤 없이 — preventScroll).
// CSS 는 그 위에 걸음 박자를 얹는다: 포커스 칸의 캐릭터가 발을 구른다.
.fme.is-demo .fme__cell:focus .fme__char {
animation-name: fmeStep; animation-duration: $dur-base; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fmeStep {
0%, 100% { transform: scale(1) translateY(0); }
40% { transform: scale(1.08) translateY(-2px); }
}
// 정답 길이 출발에서 출구까지 차례로 밝아진다 — 걷는 캐릭터가 어디로 가는지가 한눈에 읽힌다.
// 지연이 걸린 루프라 fill-mode: backwards 가 필수다 (없으면 첫 프레임에 전부 켜진 상태로 번쩍한다).
.fme.is-demo .fme__cell.is-path {
animation-name: fmePath; animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes fmePath {
0% { background-color: $subject-cream; }
10% { background-color: rgba($color, .34); }
55% { background-color: rgba($color, .34); }
75% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
// 출구 깃발 칸은 미리보기 동안 숨을 쉬어 "여기가 목적지" 를 알린다
.fme.is-demo .fme__cell.is-goal {
animation-name: fmeGoal; animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes fmeGoal {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .82; transform: scale(1.06); }
}
// 폰 폭에서는 칸을 16px 로 줄여 5행 + 스코어보드 + 안내가 200px 안에 들어오게 한다
@media (max-width: 340px) {
.fme { gap: $sp-1; }
.fme__maze { grid-template-columns: repeat(7, 16px); }
.fme__donePanel { padding: $sp-2; }
}
// reduce — 걸음 박자와 깃발 숨쉬기를 끄고 캐릭터를 상시(전환 없이) 보이게 둔다
@media (prefers-reduced-motion: reduce) {
.fme.is-demo .fme__cell:focus .fme__char,
.fme.is-demo .fme__cell.is-path,
.fme.is-demo .fme__cell.is-goal { animation: none !important; }
.fme__cell:focus .fme__char { transition: none !important; opacity: 1 !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fme {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.fme__bar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.fme__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
margin-right: auto;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.2;
color: #17141a;
}
.fme__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.fme__ico--sm {
width: 12px;
height: 12px;
}
.fme__pill {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.14);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
color: #17141a;
white-space: nowrap;
}
.fme__label {
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.fme__num {
font-weight: 800;
}
.fme__btn {
display: grid;
place-items: center;
width: 24px;
height: 24px;
flex: 0 0 auto;
border: 0;
border-radius: 4px;
padding: 0;
background: rgba(23, 20, 26, 0.14);
cursor: pointer;
color: #17141a;
}
.fme__btn:hover {
background: rgba(23, 20, 26, 0.24);
}
.fme__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 0;
}
.fme__mazeWrap {
position: relative;
}
.fme__maze {
display: grid;
gap: 4px;
justify-content: center;
grid-template-columns: repeat(7, clamp(22px, 6.4vw, 32px));
grid-template-areas: "c0r0 c1r0 c2r0 c3r0 c4r0 c5r0 c6r0" "c0r1 c1r1 c2r1 c3r1 c4r1 c5r1 c6r1" "c0r2 c1r2 c2r2 c3r2 c4r2 c5r2 c6r2" "c0r3 c1r3 c2r3 c3r3 c4r3 c5r3 c6r3" "c0r4 c1r4 c2r4 c3r4 c4r4 c5r4 c6r4";
border-radius: 16px;
padding: 4px;
background: rgba(23, 20, 26, 0.12);
justify-self: center;
}
.fme__cell, .fme__wall {
aspect-ratio: 1/1;
min-width: 0;
border-radius: 4px;
}
.fme__wall {
background: #17141a;
}
.fme__cell {
display: grid;
place-items: center;
width: 100%;
border: 0;
padding: 0;
background: #fff7e6;
cursor: pointer;
color: #0a4fe8;
}
.fme__cell.is-start {
background: rgba(255, 247, 230, 0.6);
}
.fme__cell.is-goal {
background: #0a4fe8;
}
.fme__cell:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.fme__char {
width: 68%;
height: 68%;
opacity: 0;
transform: scale(0.6);
color: #0a4fe8;
}
.fme__cell:focus .fme__char {
opacity: 1;
transform: scale(1);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fme__cell.is-goal:focus .fme__char {
color: #fff7e6;
}
.fme__cell::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: inset 0 0 0 2px rgba(10, 79, 232, 0.5);
opacity: 0;
}
.fme__cell {
position: relative;
}
.fme__cell:focus::after {
opacity: 1;
}
.fme [hidden] {
display: none !important;
}
.fme__done {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.55);
}
.fme__donePanel {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: 16px;
padding: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fme__doneTitle {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.2;
color: #17141a;
}
.fme__doneLine {
margin: 0;
font-weight: 600;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.78);
}
.fme__again {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 12px;
padding: 4px 12px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.4;
cursor: pointer;
}
.fme__again:hover {
background: #2f6df6;
}
.fme__again:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.fme__hint {
margin: 0;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.78);
}
.fme.is-demo .fme__cell:focus .fme__char {
animation-name: fmeStep;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes fmeStep {
0%, 100% {
transform: scale(1) translateY(0);
}
40% {
transform: scale(1.08) translateY(-2px);
}
}
.fme.is-demo .fme__cell.is-path {
animation-name: fmePath;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes fmePath {
0% {
background-color: #fff7e6;
}
10% {
background-color: rgba(10, 79, 232, 0.34);
}
55% {
background-color: rgba(10, 79, 232, 0.34);
}
75% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
.fme.is-demo .fme__cell.is-goal {
animation-name: fmeGoal;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes fmeGoal {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.82;
transform: scale(1.06);
}
}
@media (max-width: 340px) {
.fme {
gap: 4px;
}
.fme__maze {
grid-template-columns: repeat(7, 16px);
}
.fme__donePanel {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.fme.is-demo .fme__cell:focus .fme__char,
.fme.is-demo .fme__cell.is-path,
.fme.is-demo .fme__cell.is-goal {
animation: none !important;
}
.fme__cell:focus .fme__char {
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
}var BEST_KEY = 'p301-06-best';
var STEP_FMT = '{n} 걸음';
var CELL_LABEL = '{c}열 {r}행 통로';
var START_LABEL = '출발 칸';
var GOAL_LABEL = '출구 칸';
// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자와 같은 배치다 (아래 style.scss 의 격자 이름이 이 표를 따른다).
var MAZE = [
'S.#...G',
'.#..#.#',
'...#..#',
'#.##..#',
'......#'
];
var COLS = 7, ROWS = 5;
// 미리보기가 걷는 경로 — 출발에서 출구까지의 최단 정답 한 벌 [열, 행] (10 걸음, run/301/_probe/maze_path.py 로 구했다)
var DEMO_PATH = [[0,0],[0,1],[0,2],[1,2],[2,2],[2,1],[3,1],[3,0],[4,0],[5,0],[6,0]];
var root = document.getElementById('fme');
var maze = document.getElementById('maze');
var stepNum = document.getElementById('stepNum');
var bestNum = document.getElementById('bestNum');
var doneBox = document.getElementById('done');
var doneLine = document.getElementById('doneLine');
var cells = {};
var steps = 0;
var best = 0;
var cleared = false;
var demoTimer = 0;
var demoIdx = 0;
function key(c, r) { return c + ',' + r; }
function at(c, r) { return (MAZE[r] || '')[c] || '#'; }
function isOpen(c, r) { return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== '#'; }
function loadBest() {
try { best = Number(window.localStorage.getItem(BEST_KEY)) || 0; } catch (e) { best = 0; }
bestNum.textContent = best ? STEP_FMT.replace('{n}', String(best)) : '-';
}
function saveBest(n) {
if (best && n >= best) { return; }
best = n;
try { window.localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
bestNum.textContent = STEP_FMT.replace('{n}', String(best));
}
function build() {
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var ch = at(c, r);
var el;
if (ch === '#') {
// 벽은 div 라 포커스를 못 받는다 — 막히는 일이 규칙이 아니라 구조로 정해진다
el = document.createElement('div');
el.className = 'fme__wall';
el.setAttribute('aria-hidden', 'true');
} else {
el = document.createElement('button');
el.type = 'button';
el.className = 'fme__cell' + (ch === 'S' ? ' is-start' : '') + (ch === 'G' ? ' is-goal' : '');
el.setAttribute('data-c', String(c));
el.setAttribute('data-r', String(r));
var label = CELL_LABEL.replace('{c}', String(c + 1)).replace('{r}', String(r + 1));
if (ch === 'S') { label = START_LABEL; }
if (ch === 'G') { label = GOAL_LABEL; }
el.setAttribute('aria-label', label);
// 캐릭터는 칸마다 하나씩 숨어 있다가 그 칸이 :focus 가 되면 나타난다 — 그리는 주체는 CSS 다
el.innerHTML = '<svg class="fme__char" aria-hidden="true"><use href="#fme-char-ico"/></svg>';
cells[key(c, r)] = el;
}
el.style.gridArea = 'c' + c + 'r' + r;
maze.appendChild(el);
}
}
// 미리보기에서 정답 길이 순서대로 밝아지도록 경로 칸에 차례 번호를 심는다
for (var i = 0; i < DEMO_PATH.length; i++) {
var pc = cells[key(DEMO_PATH[i][0], DEMO_PATH[i][1])];
if (pc) { pc.classList.add('is-path'); pc.style.setProperty('--i', String(i)); }
}
}
// 스크립트가 옮기는 포커스인지 사람이 짚은 포커스인지 가르는 표식 — 미리보기가 자기 포커스에 스스로 멈추면 안 된다
var autoFocus = false;
function focusCell(c, r) {
var el = cells[key(c, r)];
if (!el) { return false; }
autoFocus = true;
// iframe 안에서 무가드 focus 는 부모 페이지를 끌어당긴다 — 언제나 preventScroll
el.focus({ preventScroll: true });
autoFocus = false;
return true;
}
function currentCell() {
var a = document.activeElement;
if (a && a.className && String(a.className).indexOf('fme__cell') >= 0) {
return [Number(a.getAttribute('data-c')), Number(a.getAttribute('data-r'))];
}
return null;
}
function reach(c, r) {
if (at(c, r) !== 'G' || cleared) { return; }
cleared = true;
doneLine.textContent = STEP_FMT.replace('{n}', String(steps));
doneBox.hidden = false;
saveBest(steps);
}
function move(dc, dr) {
var cur = currentCell();
if (!cur) { focusCell(0, 0); return; }
var nc = cur[0] + dc, nr = cur[1] + dr;
if (!isOpen(nc, nr)) { return; }
if (!focusCell(nc, nr)) { return; }
steps += 1;
stepNum.textContent = String(steps);
reach(nc, nr);
}
function reset() {
stopDemo();
cleared = false;
steps = 0;
stepNum.textContent = '0';
doneBox.hidden = true;
focusCell(0, 0);
}
function stopDemo() {
root.classList.remove('is-demo');
window.clearInterval(demoTimer);
demoTimer = 0;
}
window.addEventListener('keydown', function (e) {
var dc = 0, dr = 0;
if (e.key === 'ArrowLeft') { dc = -1; }
else if (e.key === 'ArrowRight') { dc = 1; }
else if (e.key === 'ArrowUp') { dr = -1; }
else if (e.key === 'ArrowDown') { dr = 1; }
else { return; }
e.preventDefault();
if (root.classList.contains('is-demo')) { stopDemo(); reset(); return; }
move(dc, dr);
});
maze.addEventListener('pointerdown', function () { if (root.classList.contains('is-demo')) { stopDemo(); reset(); } });
// 사람이 탭으로 미로에 들어오면 미리보기를 접는다 (스크립트가 옮긴 포커스는 그대로 둔다)
maze.addEventListener('focusin', function () {
if (!autoFocus && root.classList.contains('is-demo')) { stopDemo(); reset(); }
});
document.getElementById('restartBtn').addEventListener('click', function () { reset(); });
document.getElementById('againBtn').addEventListener('click', function () { reset(); });
// 미리보기 — 타이머가 정답 경로를 따라 포커스를 옮겨 캐릭터가 미로를 걷는다.
// 스크롤은 preventScroll 로 막고, 사람이 키를 누르거나 칸을 누르면 바로 멈춘다.
function startDemo() {
demoIdx = 0;
demoTimer = window.setInterval(function () {
if (!root.classList.contains('is-demo')) { window.clearInterval(demoTimer); return; }
var p = DEMO_PATH[demoIdx % DEMO_PATH.length];
focusCell(p[0], p[1]);
demoIdx += 1;
}, 133);
}
loadBest();
build();
focusCell(0, 0);
if (root.classList.contains('is-demo')) { startDemo(); }// FocusMazeEscape — vanilla 판(run/301/demo/06-focus-maze-escape) 의 React 포트.
// 통로 칸은 button, 벽은 div — 벽이 포커스를 못 받는 것이 곧 "막힌다" 는 규칙이다.
// keydown 이 인접 통로 칸으로 focus({preventScroll:true}) 를 옮기고 캐릭터는 :focus 칸에 나타난다.
// duration·easing·accentColor 는 props → CSS 변수로 들어온다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FocusMazeEscape.module.scss";
export type FocusMazeEscapeProps = {
duration?: string;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
const BEST_KEY = "p301-06-best";
const STEP_FMT = "{n} 걸음";
const CELL_LABEL = "{c}열 {r}행 통로";
const START_LABEL = "출발 칸";
const GOAL_LABEL = "출구 칸";
const TITLE_TEXT = "포커스 미로 탈출";
const MAZE_LABEL = "7×5 미로. 방향키로 움직이세요";
const STEPS_LABEL = "걸음";
const AGAIN_LABEL = "다시 시작";
const DONE_TITLE = "탈출!";
const HINT_TEXT = "방향키로 칸을 옮겨 깃발까지 가세요 · 벽은 포커스를 받지 않아 저절로 막힙니다";
// 7×5 미로 — . 은 통로, # 은 벽, S 는 출발, G 는 출구.
// grid-template-areas 문자(module.scss)와 같은 배치다.
const MAZE: string[] = [
"S.#...G",
".#..#.#",
"...#..#",
"#.##..#",
"......#",
];
const COLS = 7, ROWS = 5;
// 미리보기가 걷는 경로 — 출발에서 출구까지의 최단 정답 한 벌 [열, 행] (10 걸음)
const DEMO_PATH: number[][] = [[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [3, 1], [3, 0], [4, 0], [5, 0], [6, 0]];
function at(c: number, r: number): string { return (MAZE[r] || "")[c] || "#"; }
function isOpen(c: number, r: number): boolean {
return c >= 0 && c < COLS && r >= 0 && r < ROWS && at(c, r) !== "#";
}
function cellKey(c: number, r: number): string { return "fme-cell-" + c + "-" + r; }
export default function FocusMazeEscape({ duration, easing, accentColor }: FocusMazeEscapeProps) {
const [isDemo, setIsDemo] = useState(true);
const [steps, setSteps] = useState(0);
const [best, setBest] = useState(0);
const [cleared, setCleared] = useState(false);
const [demoIdx, setDemoIdx] = useState(0);
const g = useRef({ steps: 0, cleared: false, best: 0, demoIdx: 0, autoFocus: false }) as
Box<{ steps: number; cleared: boolean; best: number; demoIdx: number; autoFocus: boolean }>;
useEffect(() => {
try { g.current.best = Number(window.localStorage.getItem(BEST_KEY)) || 0; setBest(g.current.best); }
catch (e) { g.current.best = 0; setBest(0); }
// 시작 칸에 캐릭터를 올려 둔다 — 사람이 탭으로 들어와도 첫 칸이 이미 살아 있다
window.requestAnimationFrame(() => { focusCell(0, 0); });
}, []);
const focusCell = (c: number, r: number): boolean => {
const el = document.getElementById(cellKey(c, r));
if (!el) { return false; }
g.current.autoFocus = true;
// iframe 안에서 무가드 focus 는 부모 페이지를 끌어당긴다 — 언제나 preventScroll
el.focus({ preventScroll: true });
g.current.autoFocus = false;
return true;
};
const currentCell = (): number[] | null => {
const a = document.activeElement as HTMLElement | null;
if (a && a.hasAttribute("data-c")) {
return [Number(a.getAttribute("data-c")), Number(a.getAttribute("data-r"))];
}
return null;
};
const saveBest = (n: number) => {
if (g.current.best && n >= g.current.best) { return; }
g.current.best = n;
setBest(n);
try { window.localStorage.setItem(BEST_KEY, String(n)); } catch (e) { /* 저장이 막힌 창에서는 넘어간다 */ }
};
const reach = (c: number, r: number) => {
if (at(c, r) !== "G" || g.current.cleared) { return; }
g.current.cleared = true;
setCleared(true);
saveBest(g.current.steps);
};
const move = (dc: number, dr: number) => {
const cur = currentCell();
if (!cur) { focusCell(0, 0); return; }
const nc = cur[0] + dc, nr = cur[1] + dr;
if (!isOpen(nc, nr) || !focusCell(nc, nr)) { return; }
g.current.steps += 1;
setSteps(g.current.steps);
reach(nc, nr);
};
const reset = () => {
setIsDemo(false);
g.current.cleared = false;
g.current.steps = 0;
setCleared(false);
setSteps(0);
focusCell(0, 0);
};
useEffect(() => {
const onKey = (e: KeyEvt) => {
let dc = 0, dr = 0;
if (e.key === "ArrowLeft") { dc = -1; }
else if (e.key === "ArrowRight") { dc = 1; }
else if (e.key === "ArrowUp") { dr = -1; }
else if (e.key === "ArrowDown") { dr = 1; }
else { return; }
e.preventDefault();
if (g.current.autoFocus) { return; }
if (isDemoRef.current) { reset(); return; }
move(dc, dr);
};
const handler = onKey as unknown as (e: Event) => void;
window.addEventListener("keydown", handler);
return () => { window.removeEventListener("keydown", handler); };
}, []);
const isDemoRef = useRef(true) as Box<boolean>;
useEffect(() => { isDemoRef.current = isDemo; }, [isDemo]);
// 미리보기 — 타이머가 정답 경로를 따라 포커스를 옮겨 캐릭터가 미로를 걷는다.
// 사람이 키를 누르거나 칸을 누르면 reset() 이 미리보기를 뗀다.
useEffect(() => {
if (!isDemo) { return; }
const id = window.setInterval(() => {
const p = DEMO_PATH[g.current.demoIdx % DEMO_PATH.length];
g.current.demoIdx += 1;
focusCell(p[0], p[1]);
setDemoIdx(g.current.demoIdx);
}, 133);
return () => { window.clearInterval(id); };
}, [isDemo]);
// 사람이 탭으로 미로에 들어오면 미리보기를 접는다 (스크립트가 옮긴 포커스는 그대로 둔다)
const onFocusIn = () => {
if (!g.current.autoFocus && isDemoRef.current) { reset(); }
};
const vars: Vars = {};
if (duration) { vars["--duration"] = duration; }
if (easing) { vars["--easing"] = easing; }
if (accentColor) { vars["--color"] = accentColor; }
const grid: { c: number; r: number; ch: string }[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) { grid.push({ c, r, ch: at(c, r) }); }
}
const pathIdx = (c: number, r: number): number => {
for (let i = 0; i < DEMO_PATH.length; i++) {
if (DEMO_PATH[i][0] === c && DEMO_PATH[i][1] === r) { return i; }
}
return -1;
};
return (
<div className={[styles.root, isDemo ? styles.isDemo : ""].filter(Boolean).join(" ")} style={vars}>
<header className={styles.bar}>
<p className={styles.title}>
<svg className={styles.ico} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216.86 207.57a28 28 0 0 1-24.66-7.77L150.09 152H172a51.94 51.94 0 0 0 51.2-61l16.36 84.17a28 28 0 0 1-22.7 32.4" opacity=".2" />
<path d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z" />
</svg>
<span>{TITLE_TEXT}</span>
</p>
<p className={styles.pill}><span className={styles.label}>{STEPS_LABEL}</span><b className={styles.num}>{steps}</b></p>
<p className={styles.pill}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M200 48v63.1c0 39.7-31.75 72.6-71.45 72.9A72 72 0 0 1 56 112V48Z" opacity=".2" />
<path d="M232 64h-24V48a8 8 0 0 0-8-8H56a8 8 0 0 0-8 8v16H24A16 16 0 0 0 8 80v16a40 40 0 0 0 40 40h3.65A80.13 80.13 0 0 0 120 191.61V216H96a8 8 0 0 0 0 16h64a8 8 0 0 0 0-16h-24v-24.42c31.94-3.23 58.44-25.64 68.08-55.58H208a40 40 0 0 0 40-40V80a16 16 0 0 0-16-16M48 120a24 24 0 0 1-24-24V80h24v32q0 4 .39 8Zm144-8.9c0 35.52-29 64.64-64 64.9a64 64 0 0 1-64-64V56h128ZM232 96a24 24 0 0 1-24 24h-.5a82 82 0 0 0 .5-8.9V80h24Z" />
</svg>
<b className={styles.num}>{best ? STEP_FMT.replace("{n}", String(best)) : "-"}</b>
</p>
<button className={styles.btn} type="button" aria-label={AGAIN_LABEL} onClick={reset}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</svg>
</button>
</header>
<div className={styles.mazeWrap}>
<div className={styles.maze} role="group" aria-label={MAZE_LABEL} onFocus={onFocusIn}
onPointerDown={() => { if (isDemoRef.current) { reset(); } }}>
{grid.map(({ c, r, ch }) => {
const area = { gridArea: "c" + c + "r" + r } as CSSProperties;
if (ch === "#") {
return <div className={styles.wall} key={c + "-" + r} aria-hidden="true" style={area} />;
}
const pi = pathIdx(c, r);
const cls = [styles.cell,
ch === "S" ? styles.start : "",
ch === "G" ? styles.goal : "",
isDemo && pi >= 0 ? styles.path : ""].filter(Boolean).join(" ");
const label = ch === "S" ? START_LABEL : (ch === "G" ? GOAL_LABEL : CELL_LABEL.replace("{c}", String(c + 1)).replace("{r}", String(r + 1)));
const style: CSSProperties = pi >= 0 ? Object.assign({}, area, { "--i": pi } as CSSProperties) : area;
return (
<button key={c + "-" + r} id={cellKey(c, r)} type="button" className={cls}
aria-label={label} data-c={c} data-r={r} style={style}>
<svg className={styles.char} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M176 48a24 24 0 1 1-24-24a24 24 0 0 1 24 24" opacity=".2" />
<path d="M152 80a32 32 0 1 0-32-32a32 32 0 0 0 32 32m0-48a16 16 0 1 1-16 16a16 16 0 0 1 16-16m64 112a8 8 0 0 1-8 8c-35.31 0-52.95-17.81-67.12-32.12c-2.74-2.77-5.36-5.4-8-7.84l-13.43 30.88l37.2 26.57A8 8 0 0 1 160 176v56a8 8 0 0 1-16 0v-51.88l-31.07-22.2l-33.59 77.27A8 8 0 0 1 72 240a7.8 7.8 0 0 1-3.19-.67a8 8 0 0 1-4.15-10.52l54.08-124.37c-9.31-1.65-20.92 1.2-34.7 8.58a164 164 0 0 0-30.57 21.77a8 8 0 0 1-10.95-11.66c2.5-2.35 61.69-57.22 98.72-25.08c3.83 3.32 7.48 7 11 10.58C166.19 122.7 179.36 136 208 136a8 8 0 0 1 8 8" />
</svg>
</button>
);
})}
</div>
<div className={styles.done} hidden={!cleared}>
<div className={styles.donePanel}>
<p className={styles.doneTitle}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224 56v60c-19.89 17.23-38.23 18.39-56 13.48v-60c17.76 4.91 36.11 3.75 56-13.48m-120 46.52v-60C86.24 37.61 67.89 38.77 48 56v60c19.89-17.22 38.23-18.39 56-13.48v60c21.62 6 42.38 21 64 27v-60c-21.62-6-42.38-21-64-27" opacity=".2" />
<path d="M227.32 48.75a8 8 0 0 0-8.56 1.25c-28 24.22-51.72 12.48-79.21-1.13C111.07 34.76 78.78 18.79 42.76 50A8 8 0 0 0 40 56v168a8 8 0 0 0 16 0v-44.23c26.79-21.16 49.87-9.75 76.45 3.41c28.49 14.09 60.77 30.06 96.79-1.13a8 8 0 0 0 2.76-6V56a8 8 0 0 0-4.68-7.25M216 71.6v40.65c-14 11.06-27 13.22-40 10.88V79.34a60.05 60.05 0 0 0 40-7.74m-56 3.76v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18-8.91-27.55-12.18v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18.01 8.91 27.55 12.18M96 48.91v43.78a60.06 60.06 0 0 0-40 7.75V59.78c14-11.06 27-13.21 40-10.87M86.58 152A60.06 60.06 0 0 0 56 160.43v-40.65c14-11.06 27-13.22 40-10.88v43.8a65.6 65.6 0 0 0-9.42-.7m25.42 4.67v-43c6.66 2.66 13.43 6 20.45 9.48c8.82 4.37 18 8.9 27.55 12.17v43c-6.66-2.67-13.43-6-20.45-9.48c-8.82-4.37-18.01-8.9-27.55-12.17m64 26.45v-43.8a65.6 65.6 0 0 0 9.42.72a60.1 60.1 0 0 0 30.58-8.47v40.68c-14 11.06-27 13.21-40 10.87" />
</svg>
<span>{DONE_TITLE}</span>
</p>
<p className={styles.doneLine}>{STEP_FMT.replace("{n}", String(steps))}</p>
<button className={styles.again} type="button" onClick={reset}>
<svg className={styles.icoSm} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</svg>
<span>{AGAIN_LABEL}</span>
</button>
</div>
</div>
</div>
<p className={styles.hint}>{HINT_TEXT}</p>
</div>
);
}06 포커스 미로 탈출 — 측정 누적면적 7.31% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 게임 만들기 — 미니게임 9종 복붙 코드
이 데모가 속한 글
CSS 게임 만들기 — 미니게임 9종 복붙 코드CSS 게임은 브라우저가 원래 갖고 있는 선택자·키프레임·transform 만으로 규칙과 화면을 함께 굴리는 놀이판입니다. 게임엔진 없이 HTML·CSS·바닐라 JS 로 아홉 개를 짰고, 아래 격자에서 지금 돌아갑니 이 글의 데모 전부 보기


