가상 조이스틱 9종 — 조이스틱 원리부터 드론 시점까지
가상 조이스틱은 화면에 그린 원판을 엄지로 밀어 방향과 세기를 한 번에 넣는 터치 조작부다. 스틱이 가운데서 살짝만 기울어도 캐릭터가 움직이면 손떨림까지 화면에 찍힌다.
기울기를 그대로 속도로 쓰면 손떨림까지 움직인다 — 가상 조이스틱은 엄지 위치를 고쳐 읽는 데서 시작한다
01 joy-radial-deadzone-rescale
02 joy-knob-spring-return-shadow
03 joy-response-curve-fine-aim
04 joy-eight-way-sticky-sectors
05 joy-floating-base-leash
06 joy-floor-plan-pan-rubber-edge
07 joy-car-steer-slip-skid
08 joy-ship-thrust-turn-inertia
09 joy-hero-dual-stick-drone-view
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 원형 데드존 바깥을 다시 펴는 출력
- 02 놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이
- 03 작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점
- 04 경계가 끈끈한 8방향 타일 걷기
- 05 엄지를 따라 끌려오는 떠 있는 받침
- 06 관성으로 둘러보는 평면도와 고무줄 가장자리
- 07 옆으로 미끄러진 만큼 남는 바퀴 자국
- 08 추력과 회전 관성으로 나는 우주선과 배기 입자
- 09 두 스틱으로 나는 드론 시점 장면
아홉 장면은 엄지 좌표를 받침 반지름 R 로 나눈 −1~1 값 하나에서 출발한다. 01~04 는 그 값을 읽고 보여 주는 방식을, 05 는 받침을 어디에 둘지를, 06~09 는 고친 값을 지도·차·우주선·드론의 움직임으로 옮기는 방법을 다룬다. 손가락 대신 시드로 정한 가상 엄지가 매번 같은 경로를 그리므로, 숫자를 바꾼 판과 원래 판을 나란히 놓고 견줄 수 있다.
엄지가 떨기만 해도 화면이 꿈틀대는 이유
01 시연의 0.3~0.7초 구간에서 엄지는 가운데를 벗어나지 않고 떨기만 하는데, 그 사이 입력 세기가 최대 0.101 까지 오른다. 데드존 0.15 를 두지 않았다면 이 떨림이 고스란히 출력이 되어 캐릭터가 제자리에서 움찔거렸을 것으로 보인다. 데드존을 거친 원형·네모 두 출력은 그 97프레임 내내 0 이었다.
1.2초짜리 한 프레임이 평면도를 581.6px 밀어낸다
06 은 시연 1.25초에 1.2초 길이의 프레임 하나를 일부러 끼워 넣는다. 받은 시간을 한 걸음에 다 쓴 판은 평면도가 581.6px 튀면서 경계를 최대 440.8px 넘었다가 되돌아오고, 0.05초에서 자른 뒤 1/120초씩 나눠 걸은 판은 24.4px 만 옮겨 간다. 엄지 값을 고쳐 읽는 일만큼 프레임 시간을 고쳐 읽는 일도 이 편의 한 축이다.
이웃한 편과 갈리는 지점
css starfield 편이 별의 깊이에 따라 이동량을 나눠 원근을 만들었다면, 09 는 같은 나눗셈(초점 거리 ÷ 깊이)을 두 스틱이 정한 자리와 방향에서 프레임마다 다시 계산한다. 2048 게임 편의 쓸기 판정은 손을 뗀 뒤 길이·시간·각으로 네 방향 가운데 하나를 한 번 고르지만, 가상 조이스틱은 엄지가 닿아 있는 동안 방향과 세기를 쉬지 않고 이어서 읽는다. 눌림 값이 제자리로 돌아오는 방식은 버튼 눌림 효과 편이 버튼 쪽에서 따로 모았고, 계산으로 그린 캔버스 장면의 기준선은 물방울·블랙홀 캔버스 효과 편이다.
가상 조이스틱 9종 — 원형 데드존 바깥을 다시 펴는 출력·놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이·작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점·경계가 끈끈한 8방향 타일 걷기·엄지를 따라 끌려오는 떠 있는 받침·관성으로 둘러보는 평면도와 고무줄 가장자리·옆으로 미끄러진 만큼 남는 바퀴 자국·추력과 회전 관성으로 나는 우주선과 배기 입자·두 스틱으로 나는 드론 시점 장면
01·03 은 같은 엄지 값을 깎고 휘는 읽기, 02 는 놓인 손잡이가 가운데로 돌아오는 모양이고 04·05 는 방향 칸과 받침 자리라는 판정의 경계를 다룬다. 06~08 은 스틱 값을 가속으로 넣어 평면도·차·우주선에 무게를 싣고, 09 는 스틱 두 개로 나는 드론 시점이다. 아홉 장면은 캔버스 2D 하나씩에 그리며 라이브러리·WebGL·이미지 파일·외부 웹폰트를 쓰지 않고, 난수는 시드를 고정한 seeded 하나뿐이다.
480×300 무대를 2초 동안 24장 찍어 잰 움직임
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 원형·네모 데드존 출력 지도 | 28.8% | 23/23 | 41.7 |
| 02 넘쳤다 돌아오는 손잡이 | 20.3% | 23/23 | 69.5 |
| 03 응답 곡선 조준점 | 15.3% | 23/23 | 59.7 |
| 04 끈끈한 8방향 걷기 | 17.5% | 23/23 | 70.0 |
| 05 끌려오는 받침 | 30.2% | 23/23 | 64.4 |
| 06 관성 평면도 | 65.6% | 23/23 | 75.2 |
| 07 미끄러지는 차와 자국 | 13.8% | 23/23 | 80.7 |
| 08 추력 우주선 | 21.8% | 23/23 | 65.8 |
| 09 두 스틱 드론 시점 | 52.0% | 23/23 | 37.6 |
평면도 전체가 미끄러지는 06 이 65.6% 로 가장 넓고, 차 한 대와 자국만 바뀌는 07 은 13.8% 에 그치지만 강도 평균은 80.7 로 가장 높다. 바닥 격자와 안개가 화면 대부분을 채우는 09 는 52.0% 를 덮으면서 강도가 37.6 로 가장 낮아, 넓은 면이 조금씩 바뀌는 장면으로 보인다.
01원형 데드존 바깥을 다시 펴는 출력
모바일 게임 설정 화면의 데드존 미리보기처럼, 왼쪽 원판은 엄지 거리가 DEADZONE_RADIUS 0.15 안이면 출력 0, 바깥이면 (거리 − 0.15) ÷ 0.85 로 다시 펴서 기울기 0.5 를 0.412, 0.8 을 0.765 로 내보내고 방향은 엄지 그대로 둔다. 오른쪽 네모 데드존은 가로와 세로를 따로 깎는 탓에 세기 0.3 에서 출력 방향이 엄지보다 최대 30° 축 쪽으로 붙고, 세기 1 에서도 8.62° 가 남는다. 두 원판을 72×72 칸마다 계산해 출력 0 은 산호, 방향이 어긋난 칸은 민트, 지금 세기와 같은 칸은 버터 띠로 칠하는데, 그 띠가 원형에서는 고리이고 네모에서는 둥근 네모다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="같은 엄지 위치를 원형 데드존과 네모 데드존에 넣어, 출력 세기를 칸마다 색으로 칠한 두 원판 위에 출력 화살표와 각도 차를 나란히 보여 준다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 원형 데드존 바깥을 다시 펴는 출력 ──────────────────────
// 원형: 길이 m 이 DEADZONE_RADIUS 안이면 0, 밖이면 (m − DEADZONE_RADIUS)/(1 − DEADZONE_RADIUS), 방향은 그대로
const DEADZONE_RADIUS = 0.15;
// 네모: 축마다 따로 |v| ≤ SQUARE_DEADZONE 이면 0 — 한 축이 작으면 그 축이 통째로 지워져 출력이 다른 축으로 달라붙는다
const SQUARE_DEADZONE = 0.15;
const LOOP_S = 4.8;
const PANEL_R = 92;
const LEFT_X = 128;
const RIGHT_X = 352;
const PANEL_Y = 140;
const sticks = [makeStick(LEFT_X, PANEL_Y, PANEL_R)];
function axisCut(v, dz) {
const a = Math.abs(v);
return a <= dz ? 0 : Math.sign(v) * (a - dz) / (1 - dz);
}
function squareDeadzone(x, y, dz) {
let ox = axisCut(x, dz);
let oy = axisCut(y, dz);
const m = Math.hypot(ox, oy);
if (m > 1) {
ox /= m;
oy /= m;
}
return [ox, oy];
}
// 가상 엄지 — 데드존 안에서 떨다가, 오른쪽 축 가까이(약 8°) 나가고, 둘레를 돈다
const PATH_01 = [
[0, 0, 0, 0], [0.22, 0.02, 0.01, 1], [0.75, 0.07, -0.05, 1], [1.25, 0.64, -0.09, 1], [1.8, 0.58, -0.42, 1],
[2.35, 0.06, -0.74, 1], [2.95, -0.62, -0.32, 1], [3.55, -0.36, 0.64, 1], [4.15, 0.34, 0.26, 1], [4.55, 0, 0, 1], [4.8, 0, 0, 0]
];
const tremor01 = makeTremor(101);
const TREMOR_01 = 0.04;
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_01, t);
const w = tremor01(t, TREMOR_01);
return { x: s.bx + (p.x + w[0]) * s.R, y: s.by + (p.y + w[1]) * s.R, down: p.down };
}
let outRound = [0, 0];
let outSquare = [0, 0];
const TRAIL_N = 36;
const trailRound = [];
const trailSquare = [];
function computeOut() {
const s = sticks[0];
outRound = radialDeadzone(s.rx, s.ry, DEADZONE_RADIUS);
outSquare = squareDeadzone(s.rx, s.ry, SQUARE_DEADZONE);
}
// 출력 방향이 엄지 방향에서 몇 도 벗어났나(출력이 0 이면 0)
function angleOff(o) {
const s = sticks[0];
if (Math.hypot(o[0], o[1]) < 1e-6 || Math.hypot(s.rx, s.ry) < 1e-6) {
return 0;
}
return Math.abs(toDeg(wrapAngle(Math.atan2(o[1], o[0]) - Math.atan2(s.ry, s.rx))));
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
trailRound.length = 0;
trailSquare.length = 0;
computeOut();
}
function update(dt) {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
computeOut();
trailRound.push(outRound[0], outRound[1]);
trailSquare.push(outSquare[0], outSquare[1]);
while (trailRound.length > TRAIL_N * 2) {
trailRound.splice(0, 2);
trailSquare.splice(0, 2);
}
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.64 * s.R;
s.ty = s.by - 0.09 * s.R;
readRaw(s);
snapKnob(s);
computeOut();
}
function settle() {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
computeOut();
}
// 오른쪽 판을 눌러도 같은 스틱 — 판 가운데 차이만큼 옮겨 왼쪽 판 좌표로 읽는다
function mapPointer(x, y) {
return x < W / 2 ? [0, x, y] : [0, x - (RIGHT_X - LEFT_X), y];
}
function statusText() { return dirText(outRound[0], outRound[1]); }
// 출력 세기 지도 — 판마다 HEAT_CELLS × HEAT_CELLS 칸을 계산해 낮은 해상도 캔버스에 찍고 판 크기로 늘려 그린다
const HEAT_CELLS = 72;
// 지금 출력 세기와 이만큼 가까운 칸은 버터색 띠로 — 같은 세기가 나오는 엄지 자리 모양
const ISO_BAND = 0.045;
const ANGLE_TINT_DEG = 12;
function makeHeat() {
const c = document.createElement('canvas');
c.width = HEAT_CELLS;
c.height = HEAT_CELLS;
const g = c.getContext('2d');
if (!g) {
return null;
}
return [c, g, g.createImageData(HEAT_CELLS, HEAT_CELLS)];
}
const heatA = makeHeat();
const heatB = makeHeat();
const DEAD_COLOR = mix(PAPER, CORAL, 0.6);
const STRONG = [150, 128, 222];
function paintHeat(h, round, now) {
if (!h) {
return;
}
const d = h[2].data;
for (let j = 0; j < HEAT_CELLS; j += 1) {
for (let i = 0; i < HEAT_CELLS; i += 1) {
const u = (i + 0.5) / HEAT_CELLS * 2 - 1;
const v = (j + 0.5) / HEAT_CELLS * 2 - 1;
const o = (j * HEAT_CELLS + i) * 4;
if (u * u + v * v > 1) {
d[o + 3] = 0;
continue;
}
const out = round ? radialDeadzone(u, v, DEADZONE_RADIUS) : squareDeadzone(u, v, SQUARE_DEADZONE);
const m = Math.min(1, Math.hypot(out[0], out[1]));
let c = m === 0 ? DEAD_COLOR : (m < 0.6 ? mix(PAPER, LAVENDER, m / 0.6) : mix(LAVENDER, STRONG, (m - 0.6) / 0.4));
// 출력 방향이 엄지 방향에서 벗어난 칸은 민트로 — ANGLE_TINT_DEG 만큼 벗어나면 가장 진하게(축으로 달라붙는 띠)
if (m > 0) {
const off = Math.abs(toDeg(wrapAngle(Math.atan2(out[1], out[0]) - Math.atan2(v, u))));
c = mix(c, MINT, 0.85 * clamp(off / ANGLE_TINT_DEG, 0, 1));
}
const near = now === 0 ? (m === 0 ? 1 : 0) : clamp(1 - Math.abs(m - now) / ISO_BAND, 0, 1);
if (near > 0) {
c = mix(c, BUTTER, 0.85 * near);
}
d[o] = c[0];
d[o + 1] = c[1];
d[o + 2] = c[2];
d[o + 3] = 255;
}
}
h[1].putImageData(h[2], 0, 0);
}
function drawPanel(cx, round, out, trail, title) {
const s = sticks[0];
const R = PANEL_R;
const h = round ? heatA : heatB;
paintHeat(h, round, Math.min(1, Math.hypot(out[0], out[1])));
if (h) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, PANEL_Y, R, 0, TAU);
ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(h[0], cx - R, PANEL_Y - R, R * 2, R * 2);
ctx.restore();
}
ring(cx, PANEL_Y, R, 2, INK, 0.35);
// 데드존 경계선
ctx.setLineDash([4, 4]);
if (round) {
ring(cx, PANEL_Y, DEADZONE_RADIUS * R, 1.5, INK, 0.6);
}
else {
const q = SQUARE_DEADZONE * R;
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.beginPath();
ctx.moveTo(cx - R, PANEL_Y - q);
ctx.lineTo(cx + R, PANEL_Y - q);
ctx.moveTo(cx - R, PANEL_Y + q);
ctx.lineTo(cx + R, PANEL_Y + q);
ctx.moveTo(cx - q, PANEL_Y - R);
ctx.lineTo(cx - q, PANEL_Y + R);
ctx.moveTo(cx + q, PANEL_Y - R);
ctx.lineTo(cx + q, PANEL_Y + R);
ctx.stroke();
}
ctx.setLineDash([]);
// 출력 끝이 지나온 자리
const n = trail.length / 2;
for (let k = 0; k < n; k += 1) {
disc(cx + trail[k * 2] * R, PANEL_Y + trail[k * 2 + 1] * R, 2.6, VIOLET_DEEP, 0.08 + 0.5 * k / n);
}
const ks = { bx: cx, by: PANEL_Y, R: R, tx: 0, ty: 0, down: false, rx: 0, ry: 0, kx: s.kx, ky: s.ky, hx: 0, hy: 0, hdown: false };
drawKnob(ks, PAPER, 0.55);
arrow(cx, PANEL_Y, cx + out[0] * R, PANEL_Y + out[1] * R, 3, VIOLET_DEEP, 0.95);
drawThumb(s.tx + (cx - LEFT_X), s.ty, s.down);
const m = Math.hypot(out[0], out[1]);
const line = '{t} · 출력 {o} · 각도 차 {a}°'.replace('{t}', title).replace('{o}', m.toFixed(2)).replace('{a}', angleOff(out).toFixed(0));
tag(line, cx, 254, PAPER, INK, 11, 'center');
}
function draw() {
fillAll(PAPER);
drawPanel(LEFT_X, true, outRound, trailRound, '원형 데드존');
drawPanel(RIGHT_X, false, outSquare, trailSquare, '네모 데드존');
const s = sticks[0];
text('입력 {m}'.replace('{m}', Math.hypot(s.rx, s.ry).toFixed(2)), W / 2, 282, INK_SOFT, 1, 11, 600, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RadialDeadzoneRescale - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RadialDeadzoneRescale.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RadialDeadzoneRescale() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 원형 데드존 바깥을 다시 펴는 출력 ──────────────────────
// 원형: 길이 m 이 DEADZONE_RADIUS 안이면 0, 밖이면 (m − DEADZONE_RADIUS)/(1 − DEADZONE_RADIUS), 방향은 그대로
const DEADZONE_RADIUS = 0.15;
// 네모: 축마다 따로 |v| ≤ SQUARE_DEADZONE 이면 0 — 한 축이 작으면 그 축이 통째로 지워져 출력이 다른 축으로 달라붙는다
const SQUARE_DEADZONE = 0.15;
const LOOP_S = 4.8;
const PANEL_R = 92;
const LEFT_X = 128;
const RIGHT_X = 352;
const PANEL_Y = 140;
const sticks: Stick[] = [makeStick(LEFT_X, PANEL_Y, PANEL_R)];
function axisCut(v: number, dz: number): number {
const a = Math.abs(v);
return a <= dz ? 0 : Math.sign(v) * (a - dz) / (1 - dz);
}
function squareDeadzone(x: number, y: number, dz: number): [number, number] {
let ox = axisCut(x, dz);
let oy = axisCut(y, dz);
const m = Math.hypot(ox, oy);
if (m > 1) { ox /= m; oy /= m; }
return [ox, oy];
}
// 가상 엄지 — 데드존 안에서 떨다가, 오른쪽 축 가까이(약 8°) 나가고, 둘레를 돈다
const PATH_01: PathKey[] = [
[0, 0, 0, 0], [0.22, 0.02, 0.01, 1], [0.75, 0.07, -0.05, 1], [1.25, 0.64, -0.09, 1], [1.8, 0.58, -0.42, 1],
[2.35, 0.06, -0.74, 1], [2.95, -0.62, -0.32, 1], [3.55, -0.36, 0.64, 1], [4.15, 0.34, 0.26, 1], [4.55, 0, 0, 1], [4.8, 0, 0, 0]
];
const tremor01 = makeTremor(101);
const TREMOR_01 = 0.04;
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_01, t);
const w = tremor01(t, TREMOR_01);
return { x: s.bx + (p.x + w[0]) * s.R, y: s.by + (p.y + w[1]) * s.R, down: p.down };
}
let outRound: [number, number] = [0, 0];
let outSquare: [number, number] = [0, 0];
const TRAIL_N = 36;
const trailRound: number[] = [];
const trailSquare: number[] = [];
function computeOut(): void {
const s = sticks[0];
outRound = radialDeadzone(s.rx, s.ry, DEADZONE_RADIUS);
outSquare = squareDeadzone(s.rx, s.ry, SQUARE_DEADZONE);
}
// 출력 방향이 엄지 방향에서 몇 도 벗어났나(출력이 0 이면 0)
function angleOff(o: [number, number]): number {
const s = sticks[0];
if (Math.hypot(o[0], o[1]) < 1e-6 || Math.hypot(s.rx, s.ry) < 1e-6) { return 0; }
return Math.abs(toDeg(wrapAngle(Math.atan2(o[1], o[0]) - Math.atan2(s.ry, s.rx))));
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
trailRound.length = 0;
trailSquare.length = 0;
computeOut();
}
function update(dt: number): void {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
computeOut();
trailRound.push(outRound[0], outRound[1]);
trailSquare.push(outSquare[0], outSquare[1]);
while (trailRound.length > TRAIL_N * 2) { trailRound.splice(0, 2); trailSquare.splice(0, 2); }
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.64 * s.R;
s.ty = s.by - 0.09 * s.R;
readRaw(s);
snapKnob(s);
computeOut();
}
function settle(): void {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
computeOut();
}
// 오른쪽 판을 눌러도 같은 스틱 — 판 가운데 차이만큼 옮겨 왼쪽 판 좌표로 읽는다
function mapPointer(x: number, y: number): [number, number, number] {
return x < W / 2 ? [0, x, y] : [0, x - (RIGHT_X - LEFT_X), y];
}
function statusText(): string { return dirText(outRound[0], outRound[1]); }
// 출력 세기 지도 — 판마다 HEAT_CELLS × HEAT_CELLS 칸을 계산해 낮은 해상도 캔버스에 찍고 판 크기로 늘려 그린다
const HEAT_CELLS = 72;
// 지금 출력 세기와 이만큼 가까운 칸은 버터색 띠로 — 같은 세기가 나오는 엄지 자리 모양
const ISO_BAND = 0.045;
const ANGLE_TINT_DEG = 12;
function makeHeat(): [HTMLCanvasElement, CanvasRenderingContext2D, ImageData] | null {
const c = document.createElement('canvas');
c.width = HEAT_CELLS;
c.height = HEAT_CELLS;
const g = c.getContext('2d');
if (!g) { return null; }
return [c, g, g.createImageData(HEAT_CELLS, HEAT_CELLS)];
}
const heatA = makeHeat();
const heatB = makeHeat();
const DEAD_COLOR = mix(PAPER, CORAL, 0.6);
const STRONG: RGB = [150, 128, 222];
function paintHeat(h: [HTMLCanvasElement, CanvasRenderingContext2D, ImageData] | null, round: boolean, now: number): void {
if (!h) { return; }
const d = h[2].data;
for (let j = 0; j < HEAT_CELLS; j += 1) {
for (let i = 0; i < HEAT_CELLS; i += 1) {
const u = (i + 0.5) / HEAT_CELLS * 2 - 1;
const v = (j + 0.5) / HEAT_CELLS * 2 - 1;
const o = (j * HEAT_CELLS + i) * 4;
if (u * u + v * v > 1) { d[o + 3] = 0; continue; }
const out = round ? radialDeadzone(u, v, DEADZONE_RADIUS) : squareDeadzone(u, v, SQUARE_DEADZONE);
const m = Math.min(1, Math.hypot(out[0], out[1]));
let c: RGB = m === 0 ? DEAD_COLOR : (m < 0.6 ? mix(PAPER, LAVENDER, m / 0.6) : mix(LAVENDER, STRONG, (m - 0.6) / 0.4));
// 출력 방향이 엄지 방향에서 벗어난 칸은 민트로 — ANGLE_TINT_DEG 만큼 벗어나면 가장 진하게(축으로 달라붙는 띠)
if (m > 0) {
const off = Math.abs(toDeg(wrapAngle(Math.atan2(out[1], out[0]) - Math.atan2(v, u))));
c = mix(c, MINT, 0.85 * clamp(off / ANGLE_TINT_DEG, 0, 1));
}
const near = now === 0 ? (m === 0 ? 1 : 0) : clamp(1 - Math.abs(m - now) / ISO_BAND, 0, 1);
if (near > 0) { c = mix(c, BUTTER, 0.85 * near); }
d[o] = c[0]; d[o + 1] = c[1]; d[o + 2] = c[2]; d[o + 3] = 255;
}
}
h[1].putImageData(h[2], 0, 0);
}
function drawPanel(cx: number, round: boolean, out: [number, number], trail: number[], title: string): void {
const s = sticks[0];
const R = PANEL_R;
const h = round ? heatA : heatB;
paintHeat(h, round, Math.min(1, Math.hypot(out[0], out[1])));
if (h) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, PANEL_Y, R, 0, TAU);
ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(h[0], cx - R, PANEL_Y - R, R * 2, R * 2);
ctx.restore();
}
ring(cx, PANEL_Y, R, 2, INK, 0.35);
// 데드존 경계선
ctx.setLineDash([4, 4]);
if (round) {
ring(cx, PANEL_Y, DEADZONE_RADIUS * R, 1.5, INK, 0.6);
} else {
const q = SQUARE_DEADZONE * R;
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.6);
ctx.beginPath();
ctx.moveTo(cx - R, PANEL_Y - q); ctx.lineTo(cx + R, PANEL_Y - q);
ctx.moveTo(cx - R, PANEL_Y + q); ctx.lineTo(cx + R, PANEL_Y + q);
ctx.moveTo(cx - q, PANEL_Y - R); ctx.lineTo(cx - q, PANEL_Y + R);
ctx.moveTo(cx + q, PANEL_Y - R); ctx.lineTo(cx + q, PANEL_Y + R);
ctx.stroke();
}
ctx.setLineDash([]);
// 출력 끝이 지나온 자리
const n = trail.length / 2;
for (let k = 0; k < n; k += 1) {
disc(cx + trail[k * 2] * R, PANEL_Y + trail[k * 2 + 1] * R, 2.6, VIOLET_DEEP, 0.08 + 0.5 * k / n);
}
const ks: Stick = { bx: cx, by: PANEL_Y, R: R, tx: 0, ty: 0, down: false, rx: 0, ry: 0, kx: s.kx, ky: s.ky, hx: 0, hy: 0, hdown: false };
drawKnob(ks, PAPER, 0.55);
arrow(cx, PANEL_Y, cx + out[0] * R, PANEL_Y + out[1] * R, 3, VIOLET_DEEP, 0.95);
drawThumb(s.tx + (cx - LEFT_X), s.ty, s.down);
const m = Math.hypot(out[0], out[1]);
const line = '{t} · 출력 {o} · 각도 차 {a}°'.replace('{t}', title).replace('{o}', m.toFixed(2)).replace('{a}', angleOff(out).toFixed(0));
tag(line, cx, 254, PAPER, INK, 11, 'center');
}
function draw(): void {
fillAll(PAPER);
drawPanel(LEFT_X, true, outRound, trailRound, '원형 데드존');
drawPanel(RIGHT_X, false, outSquare, trailSquare, '네모 데드존');
const s = sticks[0];
text('입력 {m}'.replace('{m}', Math.hypot(s.rx, s.ry).toFixed(2)), W / 2, 282, INK_SOFT, 1, 11, 600, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'같은 엄지 위치를 원형 데드존과 네모 데드존에 넣어, 출력 세기를 칸마다 색으로 칠한 두 원판 위에 출력 화살표와 각도 차를 나란히 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 원형 데드존 바깥을 다시 펴는 출력 — 측정 누적면적 28.839% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function axisCut(v, dz) {
const a = Math.abs(v);
return a <= dz ? 0 : Math.sign(v) * (a - dz) / (1 - dz);
}
function squareDeadzone(x, y, dz) {
let ox = axisCut(x, dz);
let oy = axisCut(y, dz);
const m = Math.hypot(ox, oy);
if (m > 1) {
ox /= m;
oy /= m;
}
return [ox, oy];
}
02놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이
웹 게임 첫 화면의 조작 안내에 어울리는 장면으로, 놓인 손잡이에 가운데로 당기는 힘 KNOB_STIFFNESS 210 과 속도를 깎는 KNOB_DAMPING 12 를 1/240초마다 더한다. 감쇠비가 0.414 라 끝에서 놓으면 0.238초에 반대쪽으로 23.4% 넘치고 0.475초에 처음 쪽으로 5.5% 되넘치며, ±2% 띠 안에 끝까지 머물기 시작하는 시각은 0.571초다. 가운데에서 멀수록 손잡이를 진행 방향으로 납작하게 누르고 그림자 높이를 16px 에서 끝 5.6px 까지 줄여, 기울인 손잡이가 바닥 쪽으로 눌린 듯한 높이 차를 만든다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="엄지가 손잡이를 끌었다 놓으면 손잡이가 가운데를 한두 번 넘었다 멈추고, 오른쪽 그래프가 가로·세로 위치를 시간에 따라 그린다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 02 놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이 ──────────────
// 놓인 손잡이: 가속 = −KNOB_STIFFNESS × 위치 − KNOB_DAMPING × 속도 (가로·세로 따로, 위치 단위는 반지름 R)
const KNOB_STIFFNESS = 210;
const KNOB_DAMPING = 12;
// 속도를 먼저 고치고 그 새 속도로 위치를 옮긴다(반 걸음 적분) — 한 프레임을 1/240초 잔걸음으로 나눠 돈다
const KNOB_SUBSTEP = 1 / 240;
const LOOP_S = 4.4;
const sticks = [makeStick(132, 160, 78)];
// 가상 엄지 — 손잡이를 끌어 잠깐 쥐었다가 놓기를 세 번
const PATH_02 = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.6, 0.92, -0.3, 1], [0.7, 0.92, -0.3, 0], [1.7, 0, 0, 1],
[2.12, -0.7, 0.72, 1], [2.2, -0.7, 0.72, 0], [3.1, 0, 0, 1], [3.48, 0.12, -0.96, 1], [3.55, 0.12, -0.96, 0], [4.4, 0, 0, 0]
];
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_02, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
let vx = 0;
let vy = 0;
let wasDown = false;
// 놓은 순간의 방향과, 그 뒤 가운데를 넘어간 횟수
let relX = 0;
let relY = 0;
let lastSide = 0;
let crossings = 0;
const OVERSHOOT_MIN = 0.02;
// 그래프용 기록 — [장면 시각, 가로, 세로] 를 이어 붙인다
const GRAPH_S = 2.4;
const hist = [];
const releases = [];
function springStep(s, h) {
vx += (-KNOB_STIFFNESS * s.kx - KNOB_DAMPING * vx) * h;
vy += (-KNOB_STIFFNESS * s.ky - KNOB_DAMPING * vy) * h;
s.kx += vx * h;
s.ky += vy * h;
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
vx = 0;
vy = 0;
wasDown = false;
crossings = 0;
lastSide = 0;
hist.length = 0;
releases.length = 0;
}
function update(dt) {
const s = sticks[0];
driveSticks(sticks, autoPose);
if (s.down) {
// 쥔 동안은 손잡이가 엄지를 바로 따라가고, 그 움직임을 속도로 남겨 둔다(놓는 순간 그 속도로 출발)
const ox = s.kx;
const oy = s.ky;
followKnob(s, dt);
vx = (s.kx - ox) / dt;
vy = (s.ky - oy) / dt;
}
else {
if (wasDown) {
const m = Math.hypot(s.kx, s.ky) || 1;
relX = s.kx / m;
relY = s.ky / m;
lastSide = 1;
crossings = 0;
releases.push(sceneT);
}
let left = dt;
while (left > 1e-9) {
const h = Math.min(KNOB_SUBSTEP, left);
springStep(s, h);
left -= h;
}
const p = s.kx * relX + s.ky * relY;
if (lastSide !== 0 && Math.abs(p) > OVERSHOOT_MIN && Math.sign(p) !== lastSide) {
crossings += 1;
lastSide = Math.sign(p);
}
}
wasDown = s.down;
hist.push(sceneT, s.kx, s.ky);
while (hist.length > 3 && hist[0] < sceneT - GRAPH_S) {
hist.splice(0, 3);
}
while (releases.length && releases[0] < sceneT - GRAPH_S) {
releases.shift();
}
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.62 * s.R;
s.ty = s.by - 0.34 * s.R;
readRaw(s);
snapKnob(s);
vx = 0;
vy = 0;
}
function settle() {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
vx = 0;
vy = 0;
wasDown = s.down;
}
function mapPointer(x, y) { return x < 262 ? [0, x, y] : [-1, x, y]; }
function statusText() { return dirText(sticks[0].rx, sticks[0].ry); }
// 손잡이가 가운데에서 멀수록(m) 진행 방향으로 납작해지는 비율 — 눌려 기운 것처럼 보인다
const TILT_MAX = 0.3;
// 그림자 — 가운데에서는 높이 떠서 길고 흐리고, 멀수록(눌릴수록) 짧고 진하다
const SHADOW_MAX = 16;
const GRAPH_X = 272;
const GRAPH_W = 188;
const GRAPH_Y = 160;
const GRAPH_H = 74;
function draw() {
fillAll(PAPER);
const s = sticks[0];
const m = Math.min(1.3, Math.hypot(s.kx, s.ky));
const ang = Math.atan2(s.ky, s.kx);
// 받침 — 바닥 원판과 홈
disc(s.bx, s.by + 6, s.R + 10, INK, 0.08);
disc(s.bx, s.by, s.R + 10, PAPER_DIM, 1);
ring(s.bx, s.by, s.R + 10, 2, INK, 0.25);
disc(s.bx, s.by, s.R * 0.62, mix(PAPER_DIM, INK, 0.08), 1);
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.5;
// 대 — 받침 가운데에서 손잡이까지
seg(s.bx, s.by, x, y, 14, INK_SOFT, 0.55);
// 그림자
const lift = SHADOW_MAX * (1 - 0.65 * Math.min(1, m));
ctx.save();
ctx.shadowColor = rgba(INK, 0.16 + 0.26 * Math.min(1, m));
ctx.shadowBlur = (16 - 10 * Math.min(1, m)) * dpr;
ctx.shadowOffsetX = lift * 0.55 * dpr;
ctx.shadowOffsetY = lift * dpr;
ctx.beginPath();
ctx.ellipse(x, y, r * (1 - TILT_MAX * Math.min(1, m)), r, ang, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.restore();
ctx.beginPath();
ctx.ellipse(x, y, r * (1 - TILT_MAX * Math.min(1, m)), r, ang, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke();
// 윗면 반사 — 기운 쪽 반대편으로 밀려난다
disc(x - Math.cos(ang) * r * 0.32 * m - r * 0.18, y - Math.sin(ang) * r * 0.32 * m - r * 0.22, r * 0.34, PAPER, 0.55);
drawThumb(s.tx, s.ty, s.down);
// 그래프 — 가로(보라)·세로(민트) 위치를 지난 GRAPH_S 초 동안
box(GRAPH_X - 8, 52, GRAPH_W + 16, 210, 12, PAPER_DIM, 0.75);
seg(GRAPH_X, GRAPH_Y, GRAPH_X + GRAPH_W, GRAPH_Y, 1, INK, 0.35);
seg(GRAPH_X, GRAPH_Y - GRAPH_H, GRAPH_X, GRAPH_Y + GRAPH_H, 1, INK, 0.2);
for (const rt of releases) {
const gx = GRAPH_X + GRAPH_W - (sceneT - rt) / GRAPH_S * GRAPH_W;
ctx.setLineDash([3, 4]);
seg(gx, GRAPH_Y - GRAPH_H, gx, GRAPH_Y + GRAPH_H, 1, CORAL_DEEP, 0.7);
ctx.setLineDash([]);
text('놓음', gx + 3, GRAPH_Y - GRAPH_H + 6, CORAL_DEEP, 1, 10, 700, 'left');
}
const cols = [VIOLET_DEEP, MINT_DEEP];
for (let c = 0; c < 2; c += 1) {
ctx.beginPath();
for (let k = 0; k < hist.length; k += 3) {
const gx = GRAPH_X + GRAPH_W - (sceneT - hist[k]) / GRAPH_S * GRAPH_W;
const gy = GRAPH_Y - clamp(hist[k + 1 + c], -1.2, 1.2) / 1.2 * GRAPH_H;
if (k === 0) {
ctx.moveTo(gx, gy);
}
else {
ctx.lineTo(gx, gy);
}
}
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(cols[c], 0.9);
ctx.stroke();
}
text('가로', GRAPH_X + 4, 248, VIOLET_DEEP, 1, 11, 700, 'left');
text('세로', GRAPH_X + 44, 248, MINT_DEEP, 1, 11, 700, 'left');
text('놓은 뒤 넘침 {n}번'.replace('{n}', String(crossings)), GRAPH_X + GRAPH_W, 248, INK, 1, 11, 700, 'right');
tag('높이 {h}'.replace('{h}', lift.toFixed(1)), s.bx, 282, PAPER_DIM, INK, 11, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// KnobSpringReturn - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./KnobSpringReturn.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function KnobSpringReturn() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 02 놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이 ──────────────
// 놓인 손잡이: 가속 = −KNOB_STIFFNESS × 위치 − KNOB_DAMPING × 속도 (가로·세로 따로, 위치 단위는 반지름 R)
const KNOB_STIFFNESS = 210;
const KNOB_DAMPING = 12;
// 속도를 먼저 고치고 그 새 속도로 위치를 옮긴다(반 걸음 적분) — 한 프레임을 1/240초 잔걸음으로 나눠 돈다
const KNOB_SUBSTEP = 1 / 240;
const LOOP_S = 4.4;
const sticks: Stick[] = [makeStick(132, 160, 78)];
// 가상 엄지 — 손잡이를 끌어 잠깐 쥐었다가 놓기를 세 번
const PATH_02: PathKey[] = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.6, 0.92, -0.3, 1], [0.7, 0.92, -0.3, 0], [1.7, 0, 0, 1],
[2.12, -0.7, 0.72, 1], [2.2, -0.7, 0.72, 0], [3.1, 0, 0, 1], [3.48, 0.12, -0.96, 1], [3.55, 0.12, -0.96, 0], [4.4, 0, 0, 0]
];
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_02, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
let vx = 0;
let vy = 0;
let wasDown = false;
// 놓은 순간의 방향과, 그 뒤 가운데를 넘어간 횟수
let relX = 0;
let relY = 0;
let lastSide = 0;
let crossings = 0;
const OVERSHOOT_MIN = 0.02;
// 그래프용 기록 — [장면 시각, 가로, 세로] 를 이어 붙인다
const GRAPH_S = 2.4;
const hist: number[] = [];
const releases: number[] = [];
function springStep(s: Stick, h: number): void {
vx += (-KNOB_STIFFNESS * s.kx - KNOB_DAMPING * vx) * h;
vy += (-KNOB_STIFFNESS * s.ky - KNOB_DAMPING * vy) * h;
s.kx += vx * h;
s.ky += vy * h;
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
vx = 0; vy = 0; wasDown = false; crossings = 0; lastSide = 0;
hist.length = 0;
releases.length = 0;
}
function update(dt: number): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
if (s.down) {
// 쥔 동안은 손잡이가 엄지를 바로 따라가고, 그 움직임을 속도로 남겨 둔다(놓는 순간 그 속도로 출발)
const ox = s.kx;
const oy = s.ky;
followKnob(s, dt);
vx = (s.kx - ox) / dt;
vy = (s.ky - oy) / dt;
} else {
if (wasDown) {
const m = Math.hypot(s.kx, s.ky) || 1;
relX = s.kx / m; relY = s.ky / m; lastSide = 1; crossings = 0;
releases.push(sceneT);
}
let left = dt;
while (left > 1e-9) {
const h = Math.min(KNOB_SUBSTEP, left);
springStep(s, h);
left -= h;
}
const p = s.kx * relX + s.ky * relY;
if (lastSide !== 0 && Math.abs(p) > OVERSHOOT_MIN && Math.sign(p) !== lastSide) {
crossings += 1;
lastSide = Math.sign(p);
}
}
wasDown = s.down;
hist.push(sceneT, s.kx, s.ky);
while (hist.length > 3 && hist[0] < sceneT - GRAPH_S) { hist.splice(0, 3); }
while (releases.length && releases[0] < sceneT - GRAPH_S) { releases.shift(); }
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.62 * s.R;
s.ty = s.by - 0.34 * s.R;
readRaw(s);
snapKnob(s);
vx = 0; vy = 0;
}
function settle(): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
vx = 0; vy = 0; wasDown = s.down;
}
function mapPointer(x: number, y: number): [number, number, number] { return x < 262 ? [0, x, y] : [-1, x, y]; }
function statusText(): string { return dirText(sticks[0].rx, sticks[0].ry); }
// 손잡이가 가운데에서 멀수록(m) 진행 방향으로 납작해지는 비율 — 눌려 기운 것처럼 보인다
const TILT_MAX = 0.3;
// 그림자 — 가운데에서는 높이 떠서 길고 흐리고, 멀수록(눌릴수록) 짧고 진하다
const SHADOW_MAX = 16;
const GRAPH_X = 272;
const GRAPH_W = 188;
const GRAPH_Y = 160;
const GRAPH_H = 74;
function draw(): void {
fillAll(PAPER);
const s = sticks[0];
const m = Math.min(1.3, Math.hypot(s.kx, s.ky));
const ang = Math.atan2(s.ky, s.kx);
// 받침 — 바닥 원판과 홈
disc(s.bx, s.by + 6, s.R + 10, INK, 0.08);
disc(s.bx, s.by, s.R + 10, PAPER_DIM, 1);
ring(s.bx, s.by, s.R + 10, 2, INK, 0.25);
disc(s.bx, s.by, s.R * 0.62, mix(PAPER_DIM, INK, 0.08), 1);
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.5;
// 대 — 받침 가운데에서 손잡이까지
seg(s.bx, s.by, x, y, 14, INK_SOFT, 0.55);
// 그림자
const lift = SHADOW_MAX * (1 - 0.65 * Math.min(1, m));
ctx.save();
ctx.shadowColor = rgba(INK, 0.16 + 0.26 * Math.min(1, m));
ctx.shadowBlur = (16 - 10 * Math.min(1, m)) * dpr;
ctx.shadowOffsetX = lift * 0.55 * dpr;
ctx.shadowOffsetY = lift * dpr;
ctx.beginPath();
ctx.ellipse(x, y, r * (1 - TILT_MAX * Math.min(1, m)), r, ang, 0, TAU);
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.restore();
ctx.beginPath();
ctx.ellipse(x, y, r * (1 - TILT_MAX * Math.min(1, m)), r, ang, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.5);
ctx.stroke();
// 윗면 반사 — 기운 쪽 반대편으로 밀려난다
disc(x - Math.cos(ang) * r * 0.32 * m - r * 0.18, y - Math.sin(ang) * r * 0.32 * m - r * 0.22, r * 0.34, PAPER, 0.55);
drawThumb(s.tx, s.ty, s.down);
// 그래프 — 가로(보라)·세로(민트) 위치를 지난 GRAPH_S 초 동안
box(GRAPH_X - 8, 52, GRAPH_W + 16, 210, 12, PAPER_DIM, 0.75);
seg(GRAPH_X, GRAPH_Y, GRAPH_X + GRAPH_W, GRAPH_Y, 1, INK, 0.35);
seg(GRAPH_X, GRAPH_Y - GRAPH_H, GRAPH_X, GRAPH_Y + GRAPH_H, 1, INK, 0.2);
for (const rt of releases) {
const gx = GRAPH_X + GRAPH_W - (sceneT - rt) / GRAPH_S * GRAPH_W;
ctx.setLineDash([3, 4]);
seg(gx, GRAPH_Y - GRAPH_H, gx, GRAPH_Y + GRAPH_H, 1, CORAL_DEEP, 0.7);
ctx.setLineDash([]);
text('놓음', gx + 3, GRAPH_Y - GRAPH_H + 6, CORAL_DEEP, 1, 10, 700, 'left');
}
const cols: RGB[] = [VIOLET_DEEP, MINT_DEEP];
for (let c = 0; c < 2; c += 1) {
ctx.beginPath();
for (let k = 0; k < hist.length; k += 3) {
const gx = GRAPH_X + GRAPH_W - (sceneT - hist[k]) / GRAPH_S * GRAPH_W;
const gy = GRAPH_Y - clamp(hist[k + 1 + c], -1.2, 1.2) / 1.2 * GRAPH_H;
if (k === 0) { ctx.moveTo(gx, gy); } else { ctx.lineTo(gx, gy); }
}
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(cols[c], 0.9);
ctx.stroke();
}
text('가로', GRAPH_X + 4, 248, VIOLET_DEEP, 1, 11, 700, 'left');
text('세로', GRAPH_X + 44, 248, MINT_DEEP, 1, 11, 700, 'left');
text('놓은 뒤 넘침 {n}번'.replace('{n}', String(crossings)), GRAPH_X + GRAPH_W, 248, INK, 1, 11, 700, 'right');
tag('높이 {h}'.replace('{h}', lift.toFixed(1)), s.bx, 282, PAPER_DIM, INK, 11, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'엄지가 손잡이를 끌었다 놓으면 손잡이가 가운데를 한두 번 넘었다 멈추고, 오른쪽 그래프가 가로·세로 위치를 시간에 따라 그린다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이 — 측정 누적면적 20.33% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function springStep(s, h) {
vx += (-KNOB_STIFFNESS * s.kx - KNOB_DAMPING * vx) * h;
vy += (-KNOB_STIFFNESS * s.ky - KNOB_DAMPING * vy) * h;
s.kx += vx * h;
s.ky += vy * h;
}
03작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점
원격 카메라 화면의 미세 방향 조정 패드처럼, 데드존 0.08 을 거친 세기를 RESPONSE_EXP 1.8 제곱해 작은 기울기에서는 아주 느리게, 끝까지 기울이면 초당 300px 로 조준점을 옮긴다. 입력 0.3 은 초당 22.8px 로 곧은 비례의 31.8% 빠르기라 60px 를 가는 데 2.63초가 걸리고, 0.8 은 초당 193px 라 0.31초면 닿는다. 구석 그래프의 버터 점이 지금 입력과 출력의 자리를 따라가고, 옅은 산호 조준점이 같은 스틱을 곧은 비례로 받아 두 빠르기의 차이를 나란히 보인다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="응답 곡선을 거친 조준점과 곧은 비례로 움직이는 옅은 조준점이 같은 스틱 값으로 과녁 사이를 움직이고, 구석 그래프의 점이 입력과 출력을 따라간다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 03 작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점 ──────────
// 데드존을 거친 세기 m 을 RESPONSE_EXP 제곱 — 데드존 뒤 0.3 은 0.3^1.8 ≈ 0.11 로 줄고 1 은 그대로 1
const RESPONSE_EXP = 1.8;
const AIM_DEADZONE = 0.08;
// 출력 1 일 때 조준점 빠르기(px/초)
const AIM_SPEED = 300;
const LOOP_S = 5;
const FIELD_L = 150;
const FIELD_R = 466;
const FIELD_T = 46;
const FIELD_B = 262;
const sticks = [makeStick(80, 226, 46)];
const PATH_03 = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.5, 0.3, -0.1, 1], [1.2, 0.34, 0.14, 1], [1.5, -0.96, 0.08, 1], [1.9, -0.9, -0.3, 1],
[2.2, 0.24, 0.22, 1], [2.9, 0.3, -0.12, 1], [3.2, 0.96, 0.2, 1], [3.6, 0.9, 0.08, 1], [3.9, -0.3, -0.24, 1],
[4.5, -0.26, 0.06, 1], [4.8, 0, 0, 1], [5, 0, 0, 0]
];
const tremor03 = makeTremor(303);
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_03, t);
const w = tremor03(t, 0.02);
return { x: s.bx + (p.x + w[0]) * s.R, y: s.by + (p.y + w[1]) * s.R, down: p.down };
}
// 응답 곡선 — 방향은 두고 세기만 휜다
function responseCurve(x, y) {
const o = radialDeadzone(x, y, AIM_DEADZONE);
const m = Math.hypot(o[0], o[1]);
if (m === 0) {
return [0, 0, 0];
}
const c = Math.pow(m, RESPONSE_EXP);
return [o[0] / m * c, o[1] / m * c, m];
}
const aim = { x: 300, y: 150, trail: [] };
const aimLinear = { x: 300, y: 150, trail: [] };
let inM = 0;
let outM = 0;
// 과녁 — 시드로 뽑은 자리 일곱 개, 조준점이 안에 들면 잠깐 빛난다
const targets = [];
const glow = [];
(function () {
const r = seeded(33);
for (let k = 0; k < 7; k += 1) {
targets.push(FIELD_L + 30 + r() * (FIELD_R - FIELD_L - 60), FIELD_T + 24 + r() * (FIELD_B - FIELD_T - 48), 13 + r() * 9);
glow.push(0);
}
})();
const TRAIL_03 = 90;
function moveAim(a, vx, vy, dt) {
a.x = clamp(a.x + vx * dt, FIELD_L, FIELD_R);
a.y = clamp(a.y + vy * dt, FIELD_T, FIELD_B);
a.trail.push(a.x, a.y);
while (a.trail.length > TRAIL_03 * 2) {
a.trail.splice(0, 2);
}
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
aim.x = 300;
aim.y = 150;
aim.trail.length = 0;
aimLinear.x = 300;
aimLinear.y = 150;
aimLinear.trail.length = 0;
for (let k = 0; k < glow.length; k += 1) {
glow[k] = 0;
}
inM = 0;
outM = 0;
}
function update(dt) {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
const lin = radialDeadzone(s.rx, s.ry, AIM_DEADZONE);
moveAim(aim, c[0] * AIM_SPEED, c[1] * AIM_SPEED, dt);
moveAim(aimLinear, lin[0] * AIM_SPEED, lin[1] * AIM_SPEED, dt);
for (let k = 0; k < glow.length; k += 1) {
const hit = Math.hypot(aim.x - targets[k * 3], aim.y - targets[k * 3 + 1]) < targets[k * 3 + 2];
glow[k] = hit ? Math.min(1, glow[k] + dt * 6) : Math.max(0, glow[k] - dt * 2);
}
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.34 * s.R;
s.ty = s.by + 0.14 * s.R;
readRaw(s);
snapKnob(s);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
aim.x = targets[0];
aim.y = targets[1];
aim.trail.length = 0;
aimLinear.x = targets[0] + 60;
aimLinear.y = targets[1];
aimLinear.trail.length = 0;
}
// 멈춘 화면에서는 지금 출력으로 0.25초 걸은 자리로 바로 옮긴다
const SETTLE_STEP_S = 0.25;
function settle() {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
const lin = radialDeadzone(s.rx, s.ry, AIM_DEADZONE);
moveAim(aim, c[0] * AIM_SPEED, c[1] * AIM_SPEED, SETTLE_STEP_S);
moveAim(aimLinear, lin[0] * AIM_SPEED, lin[1] * AIM_SPEED, SETTLE_STEP_S);
}
function mapPointer(x, y) {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2 ? [0, x, y] : [-1, x, y];
}
function statusText() { return dirText(sticks[0].rx, sticks[0].ry); }
const CURVE_X = 352;
const CURVE_Y = 272;
const CURVE_S = 92;
function drawTrail(tr, c, width, a) {
const n = tr.length / 2;
for (let k = 1; k < n; k += 1) {
seg(tr[k * 2 - 2], tr[k * 2 - 1], tr[k * 2], tr[k * 2 + 1], width, c, a * k / n);
}
}
function crosshair(x, y, c, a, r) {
ring(x, y, r, 2.5, c, a);
seg(x - r - 7, y, x - r * 0.4, y, 2.5, c, a);
seg(x + r * 0.4, y, x + r + 7, y, 2.5, c, a);
seg(x, y - r - 7, x, y - r * 0.4, 2.5, c, a);
seg(x, y + r * 0.4, x, y + r + 7, 2.5, c, a);
}
function draw() {
fillAll(PAPER);
// 바닥 격자
for (let gx = FIELD_L; gx <= FIELD_R; gx += 24) {
seg(gx, FIELD_T - 8, gx, FIELD_B + 8, 1, INK, 0.06);
}
for (let gy = FIELD_T; gy <= FIELD_B; gy += 24) {
seg(FIELD_L - 8, gy, FIELD_R + 8, gy, 1, INK, 0.06);
}
for (let k = 0; k < glow.length; k += 1) {
const tx = targets[k * 3];
const ty = targets[k * 3 + 1];
const tr = targets[k * 3 + 2];
disc(tx, ty, tr * (1 + 0.25 * glow[k]), k % 2 ? SKY : PINK, 0.55 + 0.4 * glow[k]);
ring(tx, ty, tr * 0.5, 1.5, INK, 0.25);
if (glow[k] > 0) {
ring(tx, ty, tr * (1.3 + 0.4 * glow[k]), 3, BUTTER, glow[k]);
}
}
drawTrail(aimLinear.trail, CORAL_DEEP, 3, 0.45);
drawTrail(aim.trail, VIOLET_DEEP, 5, 0.7);
ctx.setLineDash([4, 3]);
crosshair(aimLinear.x, aimLinear.y, CORAL_DEEP, 0.75, 9);
ctx.setLineDash([]);
crosshair(aim.x, aim.y, VIOLET_DEEP, 1, 12);
// 빠르기 고리 — 출력 세기(= 빠르기 / AIM_SPEED)만큼 반지름이 커진다
ring(aim.x, aim.y, 18 + outM * 30, 6, LAVENDER, 0.45);
ring(aimLinear.x, aimLinear.y, 14 + inM * 30, 4, CORAL, 0.35);
// 스틱
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('응답 곡선', 80, 110, LAVENDER, INK, 11, 'center');
tag('곧은 비례', 80, 134, CORAL, INK, 11, 'center');
// 구석 곡선 그래프 — x = 입력 세기, y = 출력 세기
box(CURVE_X - 10, CURVE_Y - CURVE_S - 26, CURVE_S + 22, CURVE_S + 34, 10, PAPER, 0.92);
seg(CURVE_X, CURVE_Y, CURVE_X + CURVE_S, CURVE_Y, 1.5, INK, 0.5);
seg(CURVE_X, CURVE_Y, CURVE_X, CURVE_Y - CURVE_S, 1.5, INK, 0.5);
seg(CURVE_X, CURVE_Y, CURVE_X + CURVE_S, CURVE_Y - CURVE_S, 1.5, CORAL_DEEP, 0.55);
ctx.beginPath();
for (let k = 0; k <= 40; k += 1) {
const u = k / 40;
const px = CURVE_X + u * CURVE_S;
const py = CURVE_Y - Math.pow(u, RESPONSE_EXP) * CURVE_S;
if (k === 0) {
ctx.moveTo(px, py);
}
else {
ctx.lineTo(px, py);
}
}
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.95);
ctx.stroke();
// 지금 입력까지 곡선 아래를 칠한다 — 곧은 비례와 곡선 사이가 작은 기울기에서 덜어 낸 빠르기
ctx.beginPath();
ctx.moveTo(CURVE_X, CURVE_Y);
for (let k = 0; k <= 24; k += 1) {
const u = inM * k / 24;
ctx.lineTo(CURVE_X + u * CURVE_S, CURVE_Y - Math.pow(u, RESPONSE_EXP) * CURVE_S);
}
ctx.lineTo(CURVE_X + inM * CURVE_S, CURVE_Y);
ctx.closePath();
ctx.fillStyle = rgba(LAVENDER, 0.6);
ctx.fill();
const dx = CURVE_X + inM * CURVE_S;
const dy = CURVE_Y - outM * CURVE_S;
ctx.setLineDash([2, 3]);
seg(dx, CURVE_Y, dx, dy, 1, INK, 0.5);
seg(CURVE_X, dy, dx, dy, 1, INK, 0.5);
ctx.setLineDash([]);
disc(dx, dy, 5, BUTTER, 1);
ring(dx, dy, 5, 2, INK, 0.8);
text('입력 {i} → 출력 {o}'.replace('{i}', inM.toFixed(2)).replace('{o}', outM.toFixed(2)), CURVE_X + CURVE_S / 2, CURVE_Y - CURVE_S - 14, INK, 1, 10, 700, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ResponseCurveAim - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ResponseCurveAim.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ResponseCurveAim() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 03 작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점 ──────────
// 데드존을 거친 세기 m 을 RESPONSE_EXP 제곱 — 데드존 뒤 0.3 은 0.3^1.8 ≈ 0.11 로 줄고 1 은 그대로 1
const RESPONSE_EXP = 1.8;
const AIM_DEADZONE = 0.08;
// 출력 1 일 때 조준점 빠르기(px/초)
const AIM_SPEED = 300;
const LOOP_S = 5;
const FIELD_L = 150;
const FIELD_R = 466;
const FIELD_T = 46;
const FIELD_B = 262;
const sticks: Stick[] = [makeStick(80, 226, 46)];
const PATH_03: PathKey[] = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.5, 0.3, -0.1, 1], [1.2, 0.34, 0.14, 1], [1.5, -0.96, 0.08, 1], [1.9, -0.9, -0.3, 1],
[2.2, 0.24, 0.22, 1], [2.9, 0.3, -0.12, 1], [3.2, 0.96, 0.2, 1], [3.6, 0.9, 0.08, 1], [3.9, -0.3, -0.24, 1],
[4.5, -0.26, 0.06, 1], [4.8, 0, 0, 1], [5, 0, 0, 0]
];
const tremor03 = makeTremor(303);
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_03, t);
const w = tremor03(t, 0.02);
return { x: s.bx + (p.x + w[0]) * s.R, y: s.by + (p.y + w[1]) * s.R, down: p.down };
}
// 응답 곡선 — 방향은 두고 세기만 휜다
function responseCurve(x: number, y: number): [number, number, number] {
const o = radialDeadzone(x, y, AIM_DEADZONE);
const m = Math.hypot(o[0], o[1]);
if (m === 0) { return [0, 0, 0]; }
const c = Math.pow(m, RESPONSE_EXP);
return [o[0] / m * c, o[1] / m * c, m];
}
type Aim = { x: number; y: number; trail: number[] };
const aim: Aim = { x: 300, y: 150, trail: [] };
const aimLinear: Aim = { x: 300, y: 150, trail: [] };
let inM = 0;
let outM = 0;
// 과녁 — 시드로 뽑은 자리 일곱 개, 조준점이 안에 들면 잠깐 빛난다
const targets: number[] = [];
const glow: number[] = [];
(function (): void {
const r = seeded(33);
for (let k = 0; k < 7; k += 1) {
targets.push(FIELD_L + 30 + r() * (FIELD_R - FIELD_L - 60), FIELD_T + 24 + r() * (FIELD_B - FIELD_T - 48), 13 + r() * 9);
glow.push(0);
}
})();
const TRAIL_03 = 90;
function moveAim(a: Aim, vx: number, vy: number, dt: number): void {
a.x = clamp(a.x + vx * dt, FIELD_L, FIELD_R);
a.y = clamp(a.y + vy * dt, FIELD_T, FIELD_B);
a.trail.push(a.x, a.y);
while (a.trail.length > TRAIL_03 * 2) { a.trail.splice(0, 2); }
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
aim.x = 300; aim.y = 150; aim.trail.length = 0;
aimLinear.x = 300; aimLinear.y = 150; aimLinear.trail.length = 0;
for (let k = 0; k < glow.length; k += 1) { glow[k] = 0; }
inM = 0; outM = 0;
}
function update(dt: number): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
const lin = radialDeadzone(s.rx, s.ry, AIM_DEADZONE);
moveAim(aim, c[0] * AIM_SPEED, c[1] * AIM_SPEED, dt);
moveAim(aimLinear, lin[0] * AIM_SPEED, lin[1] * AIM_SPEED, dt);
for (let k = 0; k < glow.length; k += 1) {
const hit = Math.hypot(aim.x - targets[k * 3], aim.y - targets[k * 3 + 1]) < targets[k * 3 + 2];
glow[k] = hit ? Math.min(1, glow[k] + dt * 6) : Math.max(0, glow[k] - dt * 2);
}
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.34 * s.R;
s.ty = s.by + 0.14 * s.R;
readRaw(s);
snapKnob(s);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
aim.x = targets[0]; aim.y = targets[1]; aim.trail.length = 0;
aimLinear.x = targets[0] + 60; aimLinear.y = targets[1]; aimLinear.trail.length = 0;
}
// 멈춘 화면에서는 지금 출력으로 0.25초 걸은 자리로 바로 옮긴다
const SETTLE_STEP_S = 0.25;
function settle(): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const c = responseCurve(s.rx, s.ry);
inM = c[2];
outM = Math.hypot(c[0], c[1]);
const lin = radialDeadzone(s.rx, s.ry, AIM_DEADZONE);
moveAim(aim, c[0] * AIM_SPEED, c[1] * AIM_SPEED, SETTLE_STEP_S);
moveAim(aimLinear, lin[0] * AIM_SPEED, lin[1] * AIM_SPEED, SETTLE_STEP_S);
}
function mapPointer(x: number, y: number): [number, number, number] {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2 ? [0, x, y] : [-1, x, y];
}
function statusText(): string { return dirText(sticks[0].rx, sticks[0].ry); }
const CURVE_X = 352;
const CURVE_Y = 272;
const CURVE_S = 92;
function drawTrail(tr: number[], c: RGB, width: number, a: number): void {
const n = tr.length / 2;
for (let k = 1; k < n; k += 1) {
seg(tr[k * 2 - 2], tr[k * 2 - 1], tr[k * 2], tr[k * 2 + 1], width, c, a * k / n);
}
}
function crosshair(x: number, y: number, c: RGB, a: number, r: number): void {
ring(x, y, r, 2.5, c, a);
seg(x - r - 7, y, x - r * 0.4, y, 2.5, c, a);
seg(x + r * 0.4, y, x + r + 7, y, 2.5, c, a);
seg(x, y - r - 7, x, y - r * 0.4, 2.5, c, a);
seg(x, y + r * 0.4, x, y + r + 7, 2.5, c, a);
}
function draw(): void {
fillAll(PAPER);
// 바닥 격자
for (let gx = FIELD_L; gx <= FIELD_R; gx += 24) { seg(gx, FIELD_T - 8, gx, FIELD_B + 8, 1, INK, 0.06); }
for (let gy = FIELD_T; gy <= FIELD_B; gy += 24) { seg(FIELD_L - 8, gy, FIELD_R + 8, gy, 1, INK, 0.06); }
for (let k = 0; k < glow.length; k += 1) {
const tx = targets[k * 3];
const ty = targets[k * 3 + 1];
const tr = targets[k * 3 + 2];
disc(tx, ty, tr * (1 + 0.25 * glow[k]), k % 2 ? SKY : PINK, 0.55 + 0.4 * glow[k]);
ring(tx, ty, tr * 0.5, 1.5, INK, 0.25);
if (glow[k] > 0) { ring(tx, ty, tr * (1.3 + 0.4 * glow[k]), 3, BUTTER, glow[k]); }
}
drawTrail(aimLinear.trail, CORAL_DEEP, 3, 0.45);
drawTrail(aim.trail, VIOLET_DEEP, 5, 0.7);
ctx.setLineDash([4, 3]);
crosshair(aimLinear.x, aimLinear.y, CORAL_DEEP, 0.75, 9);
ctx.setLineDash([]);
crosshair(aim.x, aim.y, VIOLET_DEEP, 1, 12);
// 빠르기 고리 — 출력 세기(= 빠르기 / AIM_SPEED)만큼 반지름이 커진다
ring(aim.x, aim.y, 18 + outM * 30, 6, LAVENDER, 0.45);
ring(aimLinear.x, aimLinear.y, 14 + inM * 30, 4, CORAL, 0.35);
// 스틱
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('응답 곡선', 80, 110, LAVENDER, INK, 11, 'center');
tag('곧은 비례', 80, 134, CORAL, INK, 11, 'center');
// 구석 곡선 그래프 — x = 입력 세기, y = 출력 세기
box(CURVE_X - 10, CURVE_Y - CURVE_S - 26, CURVE_S + 22, CURVE_S + 34, 10, PAPER, 0.92);
seg(CURVE_X, CURVE_Y, CURVE_X + CURVE_S, CURVE_Y, 1.5, INK, 0.5);
seg(CURVE_X, CURVE_Y, CURVE_X, CURVE_Y - CURVE_S, 1.5, INK, 0.5);
seg(CURVE_X, CURVE_Y, CURVE_X + CURVE_S, CURVE_Y - CURVE_S, 1.5, CORAL_DEEP, 0.55);
ctx.beginPath();
for (let k = 0; k <= 40; k += 1) {
const u = k / 40;
const px = CURVE_X + u * CURVE_S;
const py = CURVE_Y - Math.pow(u, RESPONSE_EXP) * CURVE_S;
if (k === 0) { ctx.moveTo(px, py); } else { ctx.lineTo(px, py); }
}
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.95);
ctx.stroke();
// 지금 입력까지 곡선 아래를 칠한다 — 곧은 비례와 곡선 사이가 작은 기울기에서 덜어 낸 빠르기
ctx.beginPath();
ctx.moveTo(CURVE_X, CURVE_Y);
for (let k = 0; k <= 24; k += 1) {
const u = inM * k / 24;
ctx.lineTo(CURVE_X + u * CURVE_S, CURVE_Y - Math.pow(u, RESPONSE_EXP) * CURVE_S);
}
ctx.lineTo(CURVE_X + inM * CURVE_S, CURVE_Y);
ctx.closePath();
ctx.fillStyle = rgba(LAVENDER, 0.6);
ctx.fill();
const dx = CURVE_X + inM * CURVE_S;
const dy = CURVE_Y - outM * CURVE_S;
ctx.setLineDash([2, 3]);
seg(dx, CURVE_Y, dx, dy, 1, INK, 0.5);
seg(CURVE_X, dy, dx, dy, 1, INK, 0.5);
ctx.setLineDash([]);
disc(dx, dy, 5, BUTTER, 1);
ring(dx, dy, 5, 2, INK, 0.8);
text('입력 {i} → 출력 {o}'.replace('{i}', inM.toFixed(2)).replace('{o}', outM.toFixed(2)), CURVE_X + CURVE_S / 2, CURVE_Y - CURVE_S - 14, INK, 1, 10, 700, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'응답 곡선을 거친 조준점과 곧은 비례로 움직이는 옅은 조준점이 같은 스틱 값으로 과녁 사이를 움직이고, 구석 그래프의 점이 입력과 출력을 따라간다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점 — 측정 누적면적 15.269% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 응답 곡선 — 방향은 두고 세기만 휜다
function responseCurve(x, y) {
const o = radialDeadzone(x, y, AIM_DEADZONE);
const m = Math.hypot(o[0], o[1]);
if (m === 0) {
return [0, 0, 0];
}
const c = Math.pow(m, RESPONSE_EXP);
return [o[0] / m * c, o[1] / m * c, m];
}
04경계가 끈끈한 8방향 타일 걷기
레트로 타일 게임이나 격자 메뉴처럼 방향을 여덟 칸으로 끊어야 할 때, 각도를 45° 칸으로 나누되 지금 칸에서는 ±(22.5° + HYSTERESIS_DEG 8°) = ±30.5° 안이면 칸을 바꾸지 않는다. 민트 캐릭터는 이 여유 각을 두고, 산호 캐릭터는 같은 스틱을 여유 각 0 으로 받아 12×9 칸 격자를 0.2초에 한 칸씩 걷는다. 세기가 WALK_DEADZONE 0.3 아래면 방향이 없는 것으로 보고 제자리에 선다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="여덟 칸으로 나눈 스틱 둘레 위에서 엄지가 경계 근처를 떨 때, 여유 각이 있는 캐릭터는 한 방향으로 걷고 여유 각이 없는 캐릭터는 지그재그로 걷는다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 04 경계가 끈끈한 8방향 타일 걷기 ──────────────────────────
// 각도 θ 를 45° 칸 여덟 개로: 칸 = round(θ / 45°). 지금 칸이 있으면 그 칸 가운데에서 22.5° + HYSTERESIS_DEG 안까지는 바꾸지 않는다
const HYSTERESIS_DEG = 8;
// 세기가 이보다 작으면 방향 없음(제자리)
const WALK_DEADZONE = 0.3;
// 한 칸 걷는 데 드는 시간(초)
const TILE_STEP_S = 0.2;
const LOOP_S = 4.8;
const TILE = 26;
const GRID_X = 160;
const GRID_Y = 42;
const COLS = 12;
const ROWS = 9;
const STEP_C = [1, 1, 0, -1, -1, -1, 0, 1];
const STEP_R = [0, 1, 1, 1, 0, -1, -1, -1];
const sticks = [makeStick(80, 160, 58)];
// 가상 엄지 — 열쇠는 [시각, 각도(도), 세기, 닿음]. 오른쪽/오른쪽 아래 경계(22.5°) 근처에서 떨다가, 왼쪽 아래로, 다시 위/왼쪽 위 경계(247.5°) 근처로
const PATH_04 = [
[0, 22.5, 0, 0], [0.22, 22.5, 0, 1], [0.42, 22.5, 0.86, 1], [2.3, 23.5, 0.86, 1], [2.55, 135, 0.86, 1], [3.1, 135, 0.86, 1],
[3.35, 247.5, 0.86, 1], [4.4, 246.5, 0.86, 1], [4.6, 246.5, 0, 1], [4.8, 246.5, 0, 0]
];
const tremor04 = makeTremor(404);
// 손떨림 크기(도) — 경계를 이만큼 넘나든다
const TREMOR_DEG = 6;
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_04, t);
const a = (p.x + tremor04(t, TREMOR_DEG)[0]) * Math.PI / 180;
return { x: s.bx + Math.cos(a) * p.y * s.R, y: s.by + Math.sin(a) * p.y * s.R, down: p.down };
}
// 칸 판정 — 여유 각 hyst(도)를 둔 판(sticky)과 0 인 판(bare)
function pickSector(x, y, cur, hyst) {
if (Math.hypot(x, y) < WALK_DEADZONE) {
return -1;
}
const th = Math.atan2(y, x);
if (cur >= 0 && Math.abs(toDeg(wrapAngle(th - cur * Math.PI / 4))) <= 22.5 + hyst) {
return cur;
}
return sectorOf(x, y);
}
function makeWalker(c, r) { return { c: c, r: r, tc: c, tr: r, t: 0, moving: false, dir: -1, changes: 0, trail: [], face: 0 }; }
const sticky = makeWalker(2, 2);
const bare = makeWalker(2, 6);
function decide(w, hyst) {
const s = sticks[0];
const d = pickSector(s.rx, s.ry, w.dir, hyst);
if (d >= 0 && w.dir >= 0 && d !== w.dir) {
w.changes += 1;
}
w.dir = d;
}
function stepWalker(w, dt) {
if (w.moving) {
w.t += dt / TILE_STEP_S;
if (w.t >= 1) {
w.c = ((w.tc % COLS) + COLS) % COLS;
w.r = ((w.tr % ROWS) + ROWS) % ROWS;
w.moving = false;
w.t = 0;
w.trail.push(w.c, w.r);
while (w.trail.length > 28) {
w.trail.splice(0, 2);
}
}
}
if (!w.moving && w.dir >= 0) {
w.tc = w.c + STEP_C[w.dir];
w.tr = w.r + STEP_R[w.dir];
w.face = w.dir;
w.moving = true;
w.t = 0;
}
}
function resetWalker(w, c, r) {
w.c = c;
w.r = r;
w.tc = c;
w.tr = r;
w.t = 0;
w.moving = false;
w.dir = -1;
w.changes = 0;
w.trail.length = 0;
w.face = 0;
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
resetWalker(sticky, 2, 2);
resetWalker(bare, 2, 6);
}
function update(dt) {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
decide(sticky, HYSTERESIS_DEG);
decide(bare, 0);
stepWalker(sticky, dt);
stepWalker(bare, dt);
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + Math.cos(0.3) * 0.86 * s.R;
s.ty = s.by + Math.sin(0.3) * 0.86 * s.R;
readRaw(s);
snapKnob(s);
decide(sticky, HYSTERESIS_DEG);
decide(bare, 0);
sticky.c = 6;
sticky.face = sticky.dir;
bare.c = 6;
bare.face = bare.dir;
}
// 멈춘 화면 — 지금 방향으로 한 칸을 걸은 자리에 바로 놓는다
function settle() {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
for (const w of [sticky, bare]) {
decide(w, w === sticky ? HYSTERESIS_DEG : 0);
if (w.moving) {
w.c = ((w.tc % COLS) + COLS) % COLS;
w.r = ((w.tr % ROWS) + ROWS) % ROWS;
w.moving = false;
w.t = 0;
}
if (w.dir >= 0) {
w.c = (((w.c + STEP_C[w.dir]) % COLS) + COLS) % COLS;
w.r = (((w.r + STEP_R[w.dir]) % ROWS) + ROWS) % ROWS;
w.face = w.dir;
}
}
}
function mapPointer(x, y) { return x < GRID_X ? [0, x, y] : [-1, x, y]; }
function statusText() {
const s = sticks[0];
const pct = Math.round(clamp(Math.hypot(s.rx, s.ry), 0, 1) * 20) * 5;
return '방향 {d} · 세기 {s}%'.replace('{d}', sticky.dir < 0 ? CENTER_NAME : DIR_NAMES[sticky.dir]).replace('{s}', String(pct));
}
function wedge(x, y, r0, r1, a0, a1) {
ctx.beginPath();
ctx.arc(x, y, r1, a0, a1);
ctx.arc(x, y, r0, a1, a0, true);
ctx.closePath();
}
function walkerPos(w) {
const u = ease(w.t);
const c = w.moving ? lerp(w.c, w.tc, u) : w.c;
const r = w.moving ? lerp(w.r, w.tr, u) : w.r;
const hop = w.moving ? Math.sin(Math.PI * w.t) : 0;
return [GRID_X + (((c % COLS) + COLS) % COLS + 0.5) * TILE, GRID_Y + (((r % ROWS) + ROWS) % ROWS + 0.5) * TILE, hop];
}
function drawWalker(w, body, deep) {
for (let k = 0; k < w.trail.length / 2; k += 1) {
const a = 0.12 + 0.4 * k / (w.trail.length / 2);
box(GRID_X + w.trail[k * 2] * TILE + 3, GRID_Y + w.trail[k * 2 + 1] * TILE + 3, TILE - 6, TILE - 6, 6, body, a);
}
const p = walkerPos(w);
const x = p[0];
const y = p[1] - p[2] * 7;
disc(p[0], p[1] + 9, 9 - p[2] * 3, INK, 0.16);
ctx.beginPath();
ctx.ellipse(x, y, 11 * (1 + 0.08 * p[2]), 11 * (1 - 0.08 * p[2]), 0, 0, TAU);
ctx.fillStyle = rgba(body, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(deep, 0.9);
ctx.stroke();
const fa = w.face * Math.PI / 4;
const ex = Math.cos(fa) * 4;
const ey = Math.sin(fa) * 4;
disc(x + ex - Math.sin(fa) * 4, y + ey + Math.cos(fa) * 4 - 1, 2.2, INK, 1);
disc(x + ex + Math.sin(fa) * 4, y + ey - Math.cos(fa) * 4 - 1, 2.2, INK, 1);
}
function draw() {
fillAll(PAPER);
for (let r = 0; r < ROWS; r += 1) {
for (let c = 0; c < COLS; c += 1) {
box(GRID_X + c * TILE + 1, GRID_Y + r * TILE + 1, TILE - 2, TILE - 2, 4, (r + c) % 2 ? PAPER_DIM : mix(PAPER_DIM, SKY, 0.25), 1);
}
}
drawWalker(bare, CORAL, CORAL_DEEP);
drawWalker(sticky, MINT, MINT_DEEP);
// 스틱 둘레 여덟 칸
const s = sticks[0];
const R = s.R;
for (let k = 0; k < 8; k += 1) {
const a0 = (k * 45 - 22.5) * Math.PI / 180;
const a1 = (k * 45 + 22.5) * Math.PI / 180;
wedge(s.bx, s.by, R * WALK_DEADZONE, R + 10, a0 + 0.02, a1 - 0.02);
ctx.fillStyle = rgba(k === sticky.dir ? MINT : PAPER_DIM, k === sticky.dir ? 0.9 : 0.8);
ctx.fill();
if (k === bare.dir) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.stroke();
}
}
// 지금 칸의 여유 각 — 경계를 HYSTERESIS_DEG 만큼 바깥으로 밀어 둔 범위
if (sticky.dir >= 0) {
const a0 = (sticky.dir * 45 - 22.5 - HYSTERESIS_DEG) * Math.PI / 180;
const a1 = (sticky.dir * 45 + 22.5 + HYSTERESIS_DEG) * Math.PI / 180;
ctx.setLineDash([4, 3]);
wedge(s.bx, s.by, R + 12, R + 18, a0, a1);
ctx.fillStyle = rgba(MINT_DEEP, 0.55);
ctx.fill();
ctx.setLineDash([]);
}
ring(s.bx, s.by, R * WALK_DEADZONE, 1.5, INK, 0.35);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('여유 각 {h}° · 바뀜 {n}번'.replace('{h}', String(HYSTERESIS_DEG)).replace('{n}', String(sticky.changes)), 80, 252, MINT, INK, 10, 'center');
tag('여유 각 없음 · 바뀜 {n}번'.replace('{n}', String(bare.changes)), 80, 276, CORAL, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// EightWaySticky - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./EightWaySticky.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function EightWaySticky() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 04 경계가 끈끈한 8방향 타일 걷기 ──────────────────────────
// 각도 θ 를 45° 칸 여덟 개로: 칸 = round(θ / 45°). 지금 칸이 있으면 그 칸 가운데에서 22.5° + HYSTERESIS_DEG 안까지는 바꾸지 않는다
const HYSTERESIS_DEG = 8;
// 세기가 이보다 작으면 방향 없음(제자리)
const WALK_DEADZONE = 0.3;
// 한 칸 걷는 데 드는 시간(초)
const TILE_STEP_S = 0.2;
const LOOP_S = 4.8;
const TILE = 26;
const GRID_X = 160;
const GRID_Y = 42;
const COLS = 12;
const ROWS = 9;
const STEP_C = [1, 1, 0, -1, -1, -1, 0, 1];
const STEP_R = [0, 1, 1, 1, 0, -1, -1, -1];
const sticks: Stick[] = [makeStick(80, 160, 58)];
// 가상 엄지 — 열쇠는 [시각, 각도(도), 세기, 닿음]. 오른쪽/오른쪽 아래 경계(22.5°) 근처에서 떨다가, 왼쪽 아래로, 다시 위/왼쪽 위 경계(247.5°) 근처로
const PATH_04: PathKey[] = [
[0, 22.5, 0, 0], [0.22, 22.5, 0, 1], [0.42, 22.5, 0.86, 1], [2.3, 23.5, 0.86, 1], [2.55, 135, 0.86, 1], [3.1, 135, 0.86, 1],
[3.35, 247.5, 0.86, 1], [4.4, 246.5, 0.86, 1], [4.6, 246.5, 0, 1], [4.8, 246.5, 0, 0]
];
const tremor04 = makeTremor(404);
// 손떨림 크기(도) — 경계를 이만큼 넘나든다
const TREMOR_DEG = 6;
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_04, t);
const a = (p.x + tremor04(t, TREMOR_DEG)[0]) * Math.PI / 180;
return { x: s.bx + Math.cos(a) * p.y * s.R, y: s.by + Math.sin(a) * p.y * s.R, down: p.down };
}
// 칸 판정 — 여유 각 hyst(도)를 둔 판(sticky)과 0 인 판(bare)
function pickSector(x: number, y: number, cur: number, hyst: number): number {
if (Math.hypot(x, y) < WALK_DEADZONE) { return -1; }
const th = Math.atan2(y, x);
if (cur >= 0 && Math.abs(toDeg(wrapAngle(th - cur * Math.PI / 4))) <= 22.5 + hyst) { return cur; }
return sectorOf(x, y);
}
type Walker = { c: number; r: number; tc: number; tr: number; t: number; moving: boolean; dir: number; changes: number; trail: number[]; face: number };
function makeWalker(c: number, r: number): Walker { return { c: c, r: r, tc: c, tr: r, t: 0, moving: false, dir: -1, changes: 0, trail: [], face: 0 }; }
const sticky = makeWalker(2, 2);
const bare = makeWalker(2, 6);
function decide(w: Walker, hyst: number): void {
const s = sticks[0];
const d = pickSector(s.rx, s.ry, w.dir, hyst);
if (d >= 0 && w.dir >= 0 && d !== w.dir) { w.changes += 1; }
w.dir = d;
}
function stepWalker(w: Walker, dt: number): void {
if (w.moving) {
w.t += dt / TILE_STEP_S;
if (w.t >= 1) {
w.c = ((w.tc % COLS) + COLS) % COLS;
w.r = ((w.tr % ROWS) + ROWS) % ROWS;
w.moving = false;
w.t = 0;
w.trail.push(w.c, w.r);
while (w.trail.length > 28) { w.trail.splice(0, 2); }
}
}
if (!w.moving && w.dir >= 0) {
w.tc = w.c + STEP_C[w.dir];
w.tr = w.r + STEP_R[w.dir];
w.face = w.dir;
w.moving = true;
w.t = 0;
}
}
function resetWalker(w: Walker, c: number, r: number): void {
w.c = c; w.r = r; w.tc = c; w.tr = r; w.t = 0; w.moving = false; w.dir = -1; w.changes = 0; w.trail.length = 0; w.face = 0;
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
resetWalker(sticky, 2, 2);
resetWalker(bare, 2, 6);
}
function update(dt: number): void {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
decide(sticky, HYSTERESIS_DEG);
decide(bare, 0);
stepWalker(sticky, dt);
stepWalker(bare, dt);
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + Math.cos(0.3) * 0.86 * s.R;
s.ty = s.by + Math.sin(0.3) * 0.86 * s.R;
readRaw(s);
snapKnob(s);
decide(sticky, HYSTERESIS_DEG);
decide(bare, 0);
sticky.c = 6; sticky.face = sticky.dir;
bare.c = 6; bare.face = bare.dir;
}
// 멈춘 화면 — 지금 방향으로 한 칸을 걸은 자리에 바로 놓는다
function settle(): void {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
for (const w of [sticky, bare]) {
decide(w, w === sticky ? HYSTERESIS_DEG : 0);
if (w.moving) { w.c = ((w.tc % COLS) + COLS) % COLS; w.r = ((w.tr % ROWS) + ROWS) % ROWS; w.moving = false; w.t = 0; }
if (w.dir >= 0) {
w.c = (((w.c + STEP_C[w.dir]) % COLS) + COLS) % COLS;
w.r = (((w.r + STEP_R[w.dir]) % ROWS) + ROWS) % ROWS;
w.face = w.dir;
}
}
}
function mapPointer(x: number, y: number): [number, number, number] { return x < GRID_X ? [0, x, y] : [-1, x, y]; }
function statusText(): string {
const s = sticks[0];
const pct = Math.round(clamp(Math.hypot(s.rx, s.ry), 0, 1) * 20) * 5;
return '방향 {d} · 세기 {s}%'.replace('{d}', sticky.dir < 0 ? CENTER_NAME : DIR_NAMES[sticky.dir]).replace('{s}', String(pct));
}
function wedge(x: number, y: number, r0: number, r1: number, a0: number, a1: number): void {
ctx.beginPath();
ctx.arc(x, y, r1, a0, a1);
ctx.arc(x, y, r0, a1, a0, true);
ctx.closePath();
}
function walkerPos(w: Walker): [number, number, number] {
const u = ease(w.t);
const c = w.moving ? lerp(w.c, w.tc, u) : w.c;
const r = w.moving ? lerp(w.r, w.tr, u) : w.r;
const hop = w.moving ? Math.sin(Math.PI * w.t) : 0;
return [GRID_X + (((c % COLS) + COLS) % COLS + 0.5) * TILE, GRID_Y + (((r % ROWS) + ROWS) % ROWS + 0.5) * TILE, hop];
}
function drawWalker(w: Walker, body: RGB, deep: RGB): void {
for (let k = 0; k < w.trail.length / 2; k += 1) {
const a = 0.12 + 0.4 * k / (w.trail.length / 2);
box(GRID_X + w.trail[k * 2] * TILE + 3, GRID_Y + w.trail[k * 2 + 1] * TILE + 3, TILE - 6, TILE - 6, 6, body, a);
}
const p = walkerPos(w);
const x = p[0];
const y = p[1] - p[2] * 7;
disc(p[0], p[1] + 9, 9 - p[2] * 3, INK, 0.16);
ctx.beginPath();
ctx.ellipse(x, y, 11 * (1 + 0.08 * p[2]), 11 * (1 - 0.08 * p[2]), 0, 0, TAU);
ctx.fillStyle = rgba(body, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(deep, 0.9);
ctx.stroke();
const fa = w.face * Math.PI / 4;
const ex = Math.cos(fa) * 4;
const ey = Math.sin(fa) * 4;
disc(x + ex - Math.sin(fa) * 4, y + ey + Math.cos(fa) * 4 - 1, 2.2, INK, 1);
disc(x + ex + Math.sin(fa) * 4, y + ey - Math.cos(fa) * 4 - 1, 2.2, INK, 1);
}
function draw(): void {
fillAll(PAPER);
for (let r = 0; r < ROWS; r += 1) {
for (let c = 0; c < COLS; c += 1) {
box(GRID_X + c * TILE + 1, GRID_Y + r * TILE + 1, TILE - 2, TILE - 2, 4, (r + c) % 2 ? PAPER_DIM : mix(PAPER_DIM, SKY, 0.25), 1);
}
}
drawWalker(bare, CORAL, CORAL_DEEP);
drawWalker(sticky, MINT, MINT_DEEP);
// 스틱 둘레 여덟 칸
const s = sticks[0];
const R = s.R;
for (let k = 0; k < 8; k += 1) {
const a0 = (k * 45 - 22.5) * Math.PI / 180;
const a1 = (k * 45 + 22.5) * Math.PI / 180;
wedge(s.bx, s.by, R * WALK_DEADZONE, R + 10, a0 + 0.02, a1 - 0.02);
ctx.fillStyle = rgba(k === sticky.dir ? MINT : PAPER_DIM, k === sticky.dir ? 0.9 : 0.8);
ctx.fill();
if (k === bare.dir) {
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.stroke();
}
}
// 지금 칸의 여유 각 — 경계를 HYSTERESIS_DEG 만큼 바깥으로 밀어 둔 범위
if (sticky.dir >= 0) {
const a0 = (sticky.dir * 45 - 22.5 - HYSTERESIS_DEG) * Math.PI / 180;
const a1 = (sticky.dir * 45 + 22.5 + HYSTERESIS_DEG) * Math.PI / 180;
ctx.setLineDash([4, 3]);
wedge(s.bx, s.by, R + 12, R + 18, a0, a1);
ctx.fillStyle = rgba(MINT_DEEP, 0.55);
ctx.fill();
ctx.setLineDash([]);
}
ring(s.bx, s.by, R * WALK_DEADZONE, 1.5, INK, 0.35);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('여유 각 {h}° · 바뀜 {n}번'.replace('{h}', String(HYSTERESIS_DEG)).replace('{n}', String(sticky.changes)), 80, 252, MINT, INK, 10, 'center');
tag('여유 각 없음 · 바뀜 {n}번'.replace('{n}', String(bare.changes)), 80, 276, CORAL, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'여덟 칸으로 나눈 스틱 둘레 위에서 엄지가 경계 근처를 떨 때, 여유 각이 있는 캐릭터는 한 방향으로 걷고 여유 각이 없는 캐릭터는 지그재그로 걷는다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 경계가 끈끈한 8방향 타일 걷기 — 측정 누적면적 17.483% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 칸 판정 — 여유 각 hyst(도)를 둔 판(sticky)과 0 인 판(bare)
function pickSector(x, y, cur, hyst) {
if (Math.hypot(x, y) < WALK_DEADZONE) {
return -1;
}
const th = Math.atan2(y, x);
if (cur >= 0 && Math.abs(toDeg(wrapAngle(th - cur * Math.PI / 4))) <= 22.5 + hyst) {
return cur;
}
return sectorOf(x, y);
}
function makeWalker(c, r) { return { c: c, r: r, tc: c, tr: r, t: 0, moving: false, dir: -1, changes: 0, trail: [], face: 0 }; }
const sticky = makeWalker(2, 2);
const bare = makeWalker(2, 6);
function decide(w, hyst) {
const s = sticks[0];
const d = pickSector(s.rx, s.ry, w.dir, hyst);
if (d >= 0 && w.dir >= 0 && d !== w.dir) {
w.changes += 1;
}
w.dir = d;
}
05엄지를 따라 끌려오는 떠 있는 받침
모바일 화면 왼쪽 절반 어디서나 시작하는 이동 패드처럼, 닿은 자리에 받침이 생기고 엄지가 받침 가운데에서 BASE_RADIUS 42px 보다 멀어지면 넘은 만큼 받침을 엄지 쪽으로 끌어온다. 그래서 엄지는 테두리 밖으로 벗어나지 않고, 화면에 그린 받침은 실제 받침(점선 원)을 BASE_FOLLOW_RATE 12 로 뒤따라 미끄러지듯 온다. 오른쪽은 같은 엄지 경로를 받는 제자리 받침이라 시연 1.0초에 엄지와 손잡이 사이가 106.5px 까지 벌어지고, 끌려오는 왼쪽은 그 순간 0px 다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="왼쪽은 엄지를 따라 끌려오는 받침, 오른쪽은 제자리 받침이라 엄지가 멀리 나갔다 되돌아올 때 오른쪽 손잡이만 테두리에 걸려 늦게 돌아선다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 05 엄지를 따라 끌려오는 떠 있는 받침 ───────────────────────
// 받침 반지름(px). 엄지가 받침 가운데에서 d > BASE_RADIUS 로 멀어지면 받침을 (엄지 − 받침) × (1 − BASE_RADIUS / d) 만큼 끌어온다
const BASE_RADIUS = 42;
// 화면에 그리는 받침은 실제 받침을 이 빠르기로 뒤따른다 — 1 − e^(−rate·dt)
const BASE_FOLLOW_RATE = 12;
// 받침마다 딸린 말이 출력 1 에서 움직이는 빠르기(px/초)
const TOKEN_SPEED = 130;
const LOOP_S = 4.6;
const SPLIT_X = 240;
// 오른쪽 판 받침은 한자리에 박혀 있다 — 왼쪽 첫 터치 자리와 같은 상대 위치
const FIXED_X = 340;
const FIXED_Y = 190;
const sticks = [makeStick(100, 190, BASE_RADIUS), makeStick(FIXED_X, FIXED_Y, BASE_RADIUS)];
// 가상 엄지(왼쪽 판 px) — 닿은 자리에서 오른쪽 위로 멀리 나갔다가 왼쪽으로 되돌아오고, 놓았다 다른 자리에서 다시
const PATH_05 = [
[0, 100, 190, 0], [0.22, 100, 190, 1], [0.8, 200, 92, 1], [1.0, 206, 86, 1], [1.6, 44, 150, 1], [2.0, 34, 196, 1],
[2.3, 120, 246, 1], [2.6, 120, 246, 0], [2.9, 170, 210, 1], [3.5, 62, 104, 1], [3.9, 70, 96, 1], [4.0, 70, 96, 0], [4.6, 100, 190, 0]
];
function autoPose(i, t) {
const p = pathAt(PATH_05, t);
return { x: p.x + (i === 1 ? SPLIT_X : 0), y: p.y, down: p.down };
}
// 끌려오는 받침 — 넘은 거리만큼 받침을 엄지 쪽으로 옮긴다
function leashBase(s) {
const dx = s.tx - s.bx;
const dy = s.ty - s.by;
const d = Math.hypot(dx, dy);
if (d > s.R) {
const k = 1 - s.R / d;
s.bx += dx * k;
s.by += dy * k;
}
}
let shownX = 100;
let shownY = 190;
let wasDown05 = false;
const tokens = [{ x: 120, y: 120, trail: [] }, { x: 360, y: 120, trail: [] }];
function moveToken(tk, s, left, dt) {
tk.x += s.rx * TOKEN_SPEED * dt;
tk.y += s.ry * TOKEN_SPEED * dt;
const w = SPLIT_X - 40;
tk.x = left + 20 + ((((tk.x - left - 20) % w) + w) % w);
tk.y = 56 + ((((tk.y - 56) % 200) + 200) % 200);
tk.trail.push(tk.x, tk.y);
while (tk.trail.length > 60) {
tk.trail.splice(0, 2);
}
}
// 엄지와 손잡이 사이 거리(px) — 손잡이가 테두리에 걸려 엄지를 못 따라간 만큼
function gapPx(s) {
if (!s.down) {
return 0;
}
return Math.max(0, Math.hypot(s.tx - s.bx, s.ty - s.by) - s.R);
}
function reset() {
const a = sticks[0];
a.bx = 100;
a.by = 190;
a.kx = 0;
a.ky = 0;
a.rx = 0;
a.ry = 0;
a.down = false;
const b = sticks[1];
b.kx = 0;
b.ky = 0;
b.rx = 0;
b.ry = 0;
b.down = false;
shownX = 100;
shownY = 190;
wasDown05 = false;
tokens[0].x = 120;
tokens[0].y = 120;
tokens[0].trail.length = 0;
tokens[1].x = 360;
tokens[1].y = 120;
tokens[1].trail.length = 0;
}
function placeLeash(a) {
if (a.down && !wasDown05 && (autoOn || !keyOn[0])) {
// 닿은 자리에 받침이 생긴다(방향키는 지금 받침에서 기울인다)
a.bx = a.tx;
a.by = a.ty;
shownX = a.tx;
shownY = a.ty;
}
if (a.down) {
leashBase(a);
}
readRaw(a);
wasDown05 = a.down;
}
function update(dt) {
driveSticks(sticks, autoPose);
const a = sticks[0];
placeLeash(a);
const f = follow(BASE_FOLLOW_RATE, dt);
shownX += (a.bx - shownX) * f;
shownY += (a.by - shownY) * f;
followKnob(a, dt);
followKnob(sticks[1], dt);
moveToken(tokens[0], a, 0, dt);
moveToken(tokens[1], sticks[1], SPLIT_X, dt);
}
function restPose() {
const a = sticks[0];
const b = sticks[1];
a.down = true;
b.down = true;
a.bx = 172;
a.by = 120;
a.tx = 200;
a.ty = 92;
b.tx = 200 + SPLIT_X;
b.ty = 92;
readRaw(a);
readRaw(b);
snapKnob(a);
snapKnob(b);
shownX = a.bx;
shownY = a.by;
wasDown05 = true;
}
function settle() {
driveSticks(sticks, autoPose);
const a = sticks[0];
placeLeash(a);
shownX = a.bx;
shownY = a.by;
snapKnob(a);
snapKnob(sticks[1]);
}
function mapPointer(x, y) { return [x < SPLIT_X ? 0 : 1, x, y]; }
function statusText() { return dirText(sticks[0].rx, sticks[0].ry); }
function drawToken(tk, c, deep) {
const n = tk.trail.length / 2;
for (let k = 1; k < n; k += 1) {
if (Math.abs(tk.trail[k * 2] - tk.trail[k * 2 - 2]) > 40 || Math.abs(tk.trail[k * 2 + 1] - tk.trail[k * 2 - 1]) > 40) {
continue;
}
seg(tk.trail[k * 2 - 2], tk.trail[k * 2 - 1], tk.trail[k * 2], tk.trail[k * 2 + 1], 6, c, 0.6 * k / n);
}
box(tk.x - 10, tk.y - 10, 20, 20, 6, c, 1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(deep, 0.9);
ctx.beginPath();
ctx.roundRect(tk.x - 10, tk.y - 10, 20, 20, 6);
ctx.stroke();
}
function draw() {
fillAll(PAPER);
box(SPLIT_X, 0, W - SPLIT_X, H, 0, mix(PAPER, CORAL, 0.12), 1);
seg(SPLIT_X, 0, SPLIT_X, H, 2, INK, 0.15);
drawToken(tokens[0], MINT, MINT_DEEP);
drawToken(tokens[1], CORAL, CORAL_DEEP);
const a = sticks[0];
const b = sticks[1];
// 왼쪽 — 화면 받침은 실제 받침을 뒤따른다. 엄지가 반지름을 넘으면 받침과 엄지를 잇는 끈
if (a.down) {
drawBase(shownX, shownY, a.R, 1);
ctx.setLineDash([2, 4]);
ring(a.bx, a.by, a.R, 1.5, MINT_DEEP, 0.7);
ctx.setLineDash([]);
drawKnob(a, MINT, 1);
}
drawThumb(a.tx, a.ty, a.down);
// 오른쪽 — 받침은 제자리, 손잡이는 테두리에서 멈추고 엄지만 멀어진다
drawBase(b.bx, b.by, b.R, 1);
drawKnob(b, CORAL, 1);
const g = gapPx(b);
if (g > 1) {
ctx.setLineDash([4, 4]);
seg(b.bx + b.kx * b.R, b.by + b.ky * b.R, b.tx, b.ty, 2, CORAL_DEEP, 0.85);
ctx.setLineDash([]);
}
drawThumb(b.tx, b.ty, b.down);
tag('끌려오는 받침 · 엄지와 손잡이 {g}px'.replace('{g}', gapPx(a).toFixed(0)), SPLIT_X / 2, 282, MINT, INK, 10, 'center');
tag('제자리 받침 · 엄지와 손잡이 {g}px'.replace('{g}', g.toFixed(0)), SPLIT_X + (W - SPLIT_X) / 2, 282, CORAL, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FloatingBaseLeash - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FloatingBaseLeash.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FloatingBaseLeash() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 05 엄지를 따라 끌려오는 떠 있는 받침 ───────────────────────
// 받침 반지름(px). 엄지가 받침 가운데에서 d > BASE_RADIUS 로 멀어지면 받침을 (엄지 − 받침) × (1 − BASE_RADIUS / d) 만큼 끌어온다
const BASE_RADIUS = 42;
// 화면에 그리는 받침은 실제 받침을 이 빠르기로 뒤따른다 — 1 − e^(−rate·dt)
const BASE_FOLLOW_RATE = 12;
// 받침마다 딸린 말이 출력 1 에서 움직이는 빠르기(px/초)
const TOKEN_SPEED = 130;
const LOOP_S = 4.6;
const SPLIT_X = 240;
// 오른쪽 판 받침은 한자리에 박혀 있다 — 왼쪽 첫 터치 자리와 같은 상대 위치
const FIXED_X = 340;
const FIXED_Y = 190;
const sticks: Stick[] = [makeStick(100, 190, BASE_RADIUS), makeStick(FIXED_X, FIXED_Y, BASE_RADIUS)];
// 가상 엄지(왼쪽 판 px) — 닿은 자리에서 오른쪽 위로 멀리 나갔다가 왼쪽으로 되돌아오고, 놓았다 다른 자리에서 다시
const PATH_05: PathKey[] = [
[0, 100, 190, 0], [0.22, 100, 190, 1], [0.8, 200, 92, 1], [1.0, 206, 86, 1], [1.6, 44, 150, 1], [2.0, 34, 196, 1],
[2.3, 120, 246, 1], [2.6, 120, 246, 0], [2.9, 170, 210, 1], [3.5, 62, 104, 1], [3.9, 70, 96, 1], [4.0, 70, 96, 0], [4.6, 100, 190, 0]
];
function autoPose(i: number, t: number): Pose {
const p = pathAt(PATH_05, t);
return { x: p.x + (i === 1 ? SPLIT_X : 0), y: p.y, down: p.down };
}
// 끌려오는 받침 — 넘은 거리만큼 받침을 엄지 쪽으로 옮긴다
function leashBase(s: Stick): void {
const dx = s.tx - s.bx;
const dy = s.ty - s.by;
const d = Math.hypot(dx, dy);
if (d > s.R) {
const k = 1 - s.R / d;
s.bx += dx * k;
s.by += dy * k;
}
}
let shownX = 100;
let shownY = 190;
let wasDown05 = false;
type Token = { x: number; y: number; trail: number[] };
const tokens: Token[] = [{ x: 120, y: 120, trail: [] }, { x: 360, y: 120, trail: [] }];
function moveToken(tk: Token, s: Stick, left: number, dt: number): void {
tk.x += s.rx * TOKEN_SPEED * dt;
tk.y += s.ry * TOKEN_SPEED * dt;
const w = SPLIT_X - 40;
tk.x = left + 20 + ((((tk.x - left - 20) % w) + w) % w);
tk.y = 56 + ((((tk.y - 56) % 200) + 200) % 200);
tk.trail.push(tk.x, tk.y);
while (tk.trail.length > 60) { tk.trail.splice(0, 2); }
}
// 엄지와 손잡이 사이 거리(px) — 손잡이가 테두리에 걸려 엄지를 못 따라간 만큼
function gapPx(s: Stick): number {
if (!s.down) { return 0; }
return Math.max(0, Math.hypot(s.tx - s.bx, s.ty - s.by) - s.R);
}
function reset(): void {
const a = sticks[0];
a.bx = 100; a.by = 190; a.kx = 0; a.ky = 0; a.rx = 0; a.ry = 0; a.down = false;
const b = sticks[1];
b.kx = 0; b.ky = 0; b.rx = 0; b.ry = 0; b.down = false;
shownX = 100; shownY = 190; wasDown05 = false;
tokens[0].x = 120; tokens[0].y = 120; tokens[0].trail.length = 0;
tokens[1].x = 360; tokens[1].y = 120; tokens[1].trail.length = 0;
}
function placeLeash(a: Stick): void {
if (a.down && !wasDown05 && (autoOn || !keyOn[0])) {
// 닿은 자리에 받침이 생긴다(방향키는 지금 받침에서 기울인다)
a.bx = a.tx; a.by = a.ty;
shownX = a.tx; shownY = a.ty;
}
if (a.down) { leashBase(a); }
readRaw(a);
wasDown05 = a.down;
}
function update(dt: number): void {
driveSticks(sticks, autoPose);
const a = sticks[0];
placeLeash(a);
const f = follow(BASE_FOLLOW_RATE, dt);
shownX += (a.bx - shownX) * f;
shownY += (a.by - shownY) * f;
followKnob(a, dt);
followKnob(sticks[1], dt);
moveToken(tokens[0], a, 0, dt);
moveToken(tokens[1], sticks[1], SPLIT_X, dt);
}
function restPose(): void {
const a = sticks[0];
const b = sticks[1];
a.down = true; b.down = true;
a.bx = 172; a.by = 120; a.tx = 200; a.ty = 92;
b.tx = 200 + SPLIT_X; b.ty = 92;
readRaw(a); readRaw(b);
snapKnob(a); snapKnob(b);
shownX = a.bx; shownY = a.by;
wasDown05 = true;
}
function settle(): void {
driveSticks(sticks, autoPose);
const a = sticks[0];
placeLeash(a);
shownX = a.bx; shownY = a.by;
snapKnob(a);
snapKnob(sticks[1]);
}
function mapPointer(x: number, y: number): [number, number, number] { return [x < SPLIT_X ? 0 : 1, x, y]; }
function statusText(): string { return dirText(sticks[0].rx, sticks[0].ry); }
function drawToken(tk: Token, c: RGB, deep: RGB): void {
const n = tk.trail.length / 2;
for (let k = 1; k < n; k += 1) {
if (Math.abs(tk.trail[k * 2] - tk.trail[k * 2 - 2]) > 40 || Math.abs(tk.trail[k * 2 + 1] - tk.trail[k * 2 - 1]) > 40) { continue; }
seg(tk.trail[k * 2 - 2], tk.trail[k * 2 - 1], tk.trail[k * 2], tk.trail[k * 2 + 1], 6, c, 0.6 * k / n);
}
box(tk.x - 10, tk.y - 10, 20, 20, 6, c, 1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(deep, 0.9);
ctx.beginPath();
ctx.roundRect(tk.x - 10, tk.y - 10, 20, 20, 6);
ctx.stroke();
}
function draw(): void {
fillAll(PAPER);
box(SPLIT_X, 0, W - SPLIT_X, H, 0, mix(PAPER, CORAL, 0.12), 1);
seg(SPLIT_X, 0, SPLIT_X, H, 2, INK, 0.15);
drawToken(tokens[0], MINT, MINT_DEEP);
drawToken(tokens[1], CORAL, CORAL_DEEP);
const a = sticks[0];
const b = sticks[1];
// 왼쪽 — 화면 받침은 실제 받침을 뒤따른다. 엄지가 반지름을 넘으면 받침과 엄지를 잇는 끈
if (a.down) {
drawBase(shownX, shownY, a.R, 1);
ctx.setLineDash([2, 4]);
ring(a.bx, a.by, a.R, 1.5, MINT_DEEP, 0.7);
ctx.setLineDash([]);
drawKnob(a, MINT, 1);
}
drawThumb(a.tx, a.ty, a.down);
// 오른쪽 — 받침은 제자리, 손잡이는 테두리에서 멈추고 엄지만 멀어진다
drawBase(b.bx, b.by, b.R, 1);
drawKnob(b, CORAL, 1);
const g = gapPx(b);
if (g > 1) {
ctx.setLineDash([4, 4]);
seg(b.bx + b.kx * b.R, b.by + b.ky * b.R, b.tx, b.ty, 2, CORAL_DEEP, 0.85);
ctx.setLineDash([]);
}
drawThumb(b.tx, b.ty, b.down);
tag('끌려오는 받침 · 엄지와 손잡이 {g}px'.replace('{g}', gapPx(a).toFixed(0)), SPLIT_X / 2, 282, MINT, INK, 10, 'center');
tag('제자리 받침 · 엄지와 손잡이 {g}px'.replace('{g}', g.toFixed(0)), SPLIT_X + (W - SPLIT_X) / 2, 282, CORAL, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'왼쪽은 엄지를 따라 끌려오는 받침, 오른쪽은 제자리 받침이라 엄지가 멀리 나갔다 되돌아올 때 오른쪽 손잡이만 테두리에 걸려 늦게 돌아선다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 엄지를 따라 끌려오는 떠 있는 받침 — 측정 누적면적 30.213% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 끌려오는 받침 — 넘은 거리만큼 받침을 엄지 쪽으로 옮긴다
function leashBase(s) {
const dx = s.tx - s.bx;
const dy = s.ty - s.by;
const d = Math.hypot(dx, dy);
if (d > s.R) {
const k = 1 - s.R / d;
s.bx += dx * k;
s.by += dy * k;
}
}
06관성으로 둘러보는 평면도와 고무줄 가장자리
매장 배치도나 전시장 안내 지도를 둘러보는 화면처럼, 스틱 출력에 1600 을 곱해 속도에 더하고 1초에 속도가 0.05배가 되도록 1/120초마다 깎아 손을 떼면 미끄러지다 멈춘다. 깎는 몫을 걸음 길이의 거듭제곱으로 잡아서 초당 500px 에서 놓으면 3초 동안 30fps 164.8px·60fps 164.8px·144fps 165.2px 로 프레임률과 거의 상관없이 같은 거리를 간다. 960×620 평면도의 경계를 넘은 만큼은 60 × (1 − 1 ÷ (넘은 거리 ÷ 60 + 1)) 만 화면에 보여 60px 넘으면 30px, 600px 넘어도 54.5px 에서 버티고, 오른쪽 아래 작은 지도에는 지금 보이는 범위가 보라 사각형으로 뜬다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="스틱으로 평면도를 미끄러지듯 둘러보다 가장자리를 넘으면 고무줄처럼 버티다 돌아오고, 작은 지도에서 숨은 탭 뒤 긴 한 프레임에 튄 판과 상한을 둔 판이 갈린다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 06 관성으로 둘러보는 평면도와 고무줄 가장자리 ───────────────
// 속도 += 출력 × PAN_ACCEL × dt, 그다음 속도 ×= PAN_DAMPING^dt — 1초가 지나면 속도가 PAN_DAMPING 배로. 프레임률과 상관없이 같은 감쇠
const PAN_ACCEL = 1600;
const PAN_DAMPING = 0.05;
const PAN_DEADZONE = 0.1;
// 경계를 o px 넘었을 때 화면에는 RUBBER_LIMIT × (1 − 1 / (o / RUBBER_LIMIT + 1)) 만큼만 보인다 — 멀수록 덜 따라가고 RUBBER_LIMIT 을 넘지 않는다
const RUBBER_LIMIT = 60;
// 넘은 만큼 경계 안으로 당기는 스프링
const EDGE_STIFFNESS = 30;
const EDGE_DAMPING = 10;
// 한 프레임을 1/120초 걸음으로 나눠 걷는다
const PAN_SUBSTEP = 1 / 120;
// 프레임 하나에 넣는 시간 상한(초)
const PAN_DT_CAP = 0.05;
// 숨은 탭에서 돌아온 첫 프레임처럼, 시연 중 한 번 1.2초짜리 프레임을 두 판에 함께 넣는다
const HIDDEN_FRAME_S = 1.2;
const SPIKE_AT = 1.25;
const GHOST_SHOW_S = 1.7;
const LOOP_S = 5.6;
const WORLD_W = 960;
const WORLD_H = 620;
const MAX_X = WORLD_W - W;
const MAX_Y = WORLD_H - H;
const sticks = [makeStick(66, 232, 42)];
const PATH_06 = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.5, 0.85, 0.5, 1], [1.6, 0.85, 0.5, 1], [1.8, 0, 0, 1], [2.4, 0.9, -0.12, 1],
[3.3, 0.9, -0.12, 1], [3.5, 0, 0, 1], [4.0, -0.8, -0.6, 1], [5.0, -0.8, -0.6, 1], [5.3, 0, 0, 1], [5.6, 0, 0, 0]
];
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_06, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
const cam = { x: 120, y: 60, vx: 0, vy: 0 };
const ghost = { x: 120, y: 60, vx: 0, vy: 0 };
let ghostUntil = -1;
let jumpCam = 0;
let jumpGhost = 0;
function overOf(v, max) { return v < 0 ? v : (v > max ? v - max : 0); }
function rubber(o) {
const a = Math.abs(o);
return Math.sign(o) * RUBBER_LIMIT * (1 - 1 / (a / RUBBER_LIMIT + 1));
}
// 화면에 보이는 자리 = 경계 안으로 자른 자리 + 넘은 만큼의 고무줄 몫
function shownX(p) { return clamp(p.x, 0, MAX_X) + rubber(overOf(p.x, MAX_X)); }
function shownY(p) { return clamp(p.y, 0, MAX_Y) + rubber(overOf(p.y, MAX_Y)); }
function accelOf(p, ox, oy) {
const ex = overOf(p.x, MAX_X);
const ey = overOf(p.y, MAX_Y);
return [ox * PAN_ACCEL - (ex ? EDGE_STIFFNESS * ex + EDGE_DAMPING * p.vx : 0),
oy * PAN_ACCEL - (ey ? EDGE_STIFFNESS * ey + EDGE_DAMPING * p.vy : 0)];
}
// 고친 판 — 상한으로 자른 뒤 잔걸음으로 나눠, 걸음마다 속도를 먼저 고치고 위치를 옮긴다
function stepPan(p, ox, oy, dt) {
let left = Math.min(dt, PAN_DT_CAP);
while (left > 1e-9) {
const h = Math.min(PAN_SUBSTEP, left);
const a = accelOf(p, ox, oy);
const keep = Math.pow(PAN_DAMPING, h);
p.vx = (p.vx + a[0] * h) * keep;
p.vy = (p.vy + a[1] * h) * keep;
p.x += p.vx * h;
p.y += p.vy * h;
left -= h;
}
}
// 깨진 판 — 받은 dt 를 그대로 한 걸음에. 지난 속도로 위치를 먼저 옮기므로 긴 프레임이면 그 속도 × dt 만큼 한 번에 튄다
function naiveStep(p, ox, oy, dt) {
const a = accelOf(p, ox, oy);
p.x += p.vx * dt;
p.y += p.vy * dt;
const keep = Math.pow(PAN_DAMPING, dt);
p.vx = (p.vx + a[0] * dt) * keep;
p.vy = (p.vy + a[1] * dt) * keep;
}
function copyPan(to, from) { to.x = from.x; to.y = from.y; to.vx = from.vx; to.vy = from.vy; }
// 평면도 — 시드로 방을 나눈다 [x, y, w, h, 색 번호] 와 가구 [x, y, w, h]
const rooms = [];
const furniture = [];
(function () {
const r = seeded(66);
function split(x, y, w, h, depth) {
if (depth >= 4 || (w < 260 && h < 220)) {
rooms.push(x, y, w, h, Math.floor(r() * 5));
const n = 1 + Math.floor(r() * 3);
for (let k = 0; k < n; k += 1) {
const fw = 26 + r() * 50;
const fh = 20 + r() * 34;
furniture.push(x + 14 + r() * Math.max(1, w - fw - 28), y + 14 + r() * Math.max(1, h - fh - 28), fw, fh);
}
return;
}
const vertical = w / h > 1.1 ? true : (h / w > 1.1 ? false : r() < 0.5);
const k = 0.35 + r() * 0.3;
if (vertical) {
split(x, y, w * k, h, depth + 1);
split(x + w * k, y, w * (1 - k), h, depth + 1);
}
else {
split(x, y, w, h * k, depth + 1);
split(x, y + h * k, w, h * (1 - k), depth + 1);
}
}
split(0, 0, WORLD_W, WORLD_H, 0);
})();
let outX = 0;
let outY = 0;
let prevT06 = 0;
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
cam.x = 120;
cam.y = 60;
cam.vx = 0;
cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1;
jumpCam = 0;
jumpGhost = 0;
prevT06 = 0;
}
function update(dt) {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const o = radialDeadzone(s.rx, s.ry, PAN_DEADZONE);
outX = o[0];
outY = o[1];
if (autoOn && prevT06 < SPIKE_AT && sceneT >= SPIKE_AT) {
// 긴 한 프레임 — 고친 판은 상한으로 잘라 나눠 걷고, 깨진 판은 그대로 한 걸음
copyPan(ghost, cam);
const cx0 = cam.x;
const cy0 = cam.y;
stepPan(cam, outX, outY, HIDDEN_FRAME_S);
naiveStep(ghost, outX, outY, HIDDEN_FRAME_S);
jumpCam = Math.hypot(cam.x - cx0, cam.y - cy0);
jumpGhost = Math.hypot(ghost.x - cx0, ghost.y - cy0);
ghostUntil = sceneT + GHOST_SHOW_S;
}
prevT06 = sceneT;
stepPan(cam, outX, outY, dt);
if (sceneT < ghostUntil) {
stepPan(ghost, outX, outY, dt);
}
else {
copyPan(ghost, cam);
}
}
function restPose() {
const s = sticks[0];
s.down = false;
readRaw(s);
snapKnob(s);
cam.x = 240;
cam.y = 160;
cam.vx = 0;
cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1;
}
// 멈춘 화면 — 지금 출력으로 SETTLE_PAN px 옮긴 자리, 경계 밖이면 경계 위
const SETTLE_PAN = 60;
function settle() {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const o = radialDeadzone(s.rx, s.ry, PAN_DEADZONE);
outX = o[0];
outY = o[1];
cam.x = clamp(cam.x + outX * SETTLE_PAN, 0, MAX_X);
cam.y = clamp(cam.y + outY * SETTLE_PAN, 0, MAX_Y);
cam.vx = 0;
cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1;
}
function mapPointer(x, y) {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.2 ? [0, x, y] : [-1, x, y];
}
function statusText() { return dirText(outX, outY); }
const ROOM_COLORS = [mix(PAPER, LAVENDER, 0.55), mix(PAPER, MINT, 0.55), mix(PAPER, BUTTER, 0.6), mix(PAPER, SKY, 0.55), mix(PAPER, PINK, 0.55)];
const MAP_X = 346;
const MAP_Y = 198;
const MAP_S = 124 / WORLD_W;
function drawPlan(ox, oy, k, wall) {
for (let i = 0; i < rooms.length; i += 5) {
box(ox + rooms[i] * k, oy + rooms[i + 1] * k, rooms[i + 2] * k, rooms[i + 3] * k, 0, ROOM_COLORS[rooms[i + 4]], 1);
}
for (let i = 0; i < furniture.length; i += 4) {
box(ox + furniture[i] * k, oy + furniture[i + 1] * k, furniture[i + 2] * k, furniture[i + 3] * k, 6 * k, PAPER, 0.85);
}
ctx.lineWidth = wall;
ctx.strokeStyle = rgba(INK, 0.7);
for (let i = 0; i < rooms.length; i += 5) {
ctx.strokeRect(ox + rooms[i] * k, oy + rooms[i + 1] * k, rooms[i + 2] * k, rooms[i + 3] * k);
}
}
function draw() {
// 평면도 바깥 — 넘친 자리에 보이는 바탕
fillAll(mix(PAPER_DIM, INK, 0.1));
const vx = shownX(cam);
const vy = shownY(cam);
drawPlan(-vx, -vy, 1, 3);
const diverged = sceneT < ghostUntil;
if (diverged) {
ctx.setLineDash([6, 5]);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.strokeRect(shownX(ghost) - vx + 6, shownY(ghost) - vy + 6, W - 12, H - 12);
ctx.setLineDash([]);
}
// 스틱
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
// 작은 지도 — 지금 보이는 범위(보라)와 숨은 탭 뒤 상한 없이 튄 판(산호)
box(MAP_X - 6, MAP_Y - 6, WORLD_W * MAP_S + 12, WORLD_H * MAP_S + 12, 8, PAPER, 0.95);
drawPlan(MAP_X, MAP_Y, MAP_S, 1);
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.strokeRect(MAP_X + vx * MAP_S, MAP_Y + vy * MAP_S, W * MAP_S, H * MAP_S);
if (diverged) {
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.strokeRect(MAP_X + shownX(ghost) * MAP_S, MAP_Y + shownY(ghost) * MAP_S, W * MAP_S, H * MAP_S);
tag('숨은 탭 {s}초 한 프레임'.replace('{s}', HIDDEN_FRAME_S.toFixed(1)), MAP_X + 62, MAP_Y - 52, BUTTER, INK, 10, 'center');
tag('상한 없음 · {g}px 튐'.replace('{g}', jumpGhost.toFixed(0)), MAP_X + 62, MAP_Y - 32, CORAL, INK, 10, 'center');
tag('상한 {c}초 · {m}px'.replace('{c}', PAN_DT_CAP.toFixed(2)).replace('{m}', jumpCam.toFixed(0)), MAP_X + 62, MAP_Y - 14, MINT, INK, 10, 'center');
}
const ox = overOf(cam.x, MAX_X);
const oy = overOf(cam.y, MAX_Y);
if (ox || oy) {
tag('넘침 {o}px → 보임 {r}px'.replace('{o}', Math.hypot(ox, oy).toFixed(0)).replace('{r}', Math.hypot(rubber(ox), rubber(oy)).toFixed(0)), 170, 282, PAPER, INK, 10, 'center');
}
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
// 처음 보이는 순간 — 자동 시연을 이 시각만큼 먼저 돌려 두어 열자마자 어긋난 판의 점선 틀이 보인다
const START_S = 0.9;
function play() {
if (autoOn) {
sceneT = 0;
reset();
for (let t = 0; t < START_S; t += 1 / 60) {
advance(1 / 60);
}
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FloorPlanPan - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FloorPlanPan.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FloorPlanPan() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 06 관성으로 둘러보는 평면도와 고무줄 가장자리 ───────────────
// 속도 += 출력 × PAN_ACCEL × dt, 그다음 속도 ×= PAN_DAMPING^dt — 1초가 지나면 속도가 PAN_DAMPING 배로. 프레임률과 상관없이 같은 감쇠
const PAN_ACCEL = 1600;
const PAN_DAMPING = 0.05;
const PAN_DEADZONE = 0.1;
// 경계를 o px 넘었을 때 화면에는 RUBBER_LIMIT × (1 − 1 / (o / RUBBER_LIMIT + 1)) 만큼만 보인다 — 멀수록 덜 따라가고 RUBBER_LIMIT 을 넘지 않는다
const RUBBER_LIMIT = 60;
// 넘은 만큼 경계 안으로 당기는 스프링
const EDGE_STIFFNESS = 30;
const EDGE_DAMPING = 10;
// 한 프레임을 1/120초 걸음으로 나눠 걷는다
const PAN_SUBSTEP = 1 / 120;
// 프레임 하나에 넣는 시간 상한(초)
const PAN_DT_CAP = 0.05;
// 숨은 탭에서 돌아온 첫 프레임처럼, 시연 중 한 번 1.2초짜리 프레임을 두 판에 함께 넣는다
const HIDDEN_FRAME_S = 1.2;
const SPIKE_AT = 1.25;
const GHOST_SHOW_S = 1.7;
const LOOP_S = 5.6;
const WORLD_W = 960;
const WORLD_H = 620;
const MAX_X = WORLD_W - W;
const MAX_Y = WORLD_H - H;
const sticks: Stick[] = [makeStick(66, 232, 42)];
const PATH_06: PathKey[] = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.5, 0.85, 0.5, 1], [1.6, 0.85, 0.5, 1], [1.8, 0, 0, 1], [2.4, 0.9, -0.12, 1],
[3.3, 0.9, -0.12, 1], [3.5, 0, 0, 1], [4.0, -0.8, -0.6, 1], [5.0, -0.8, -0.6, 1], [5.3, 0, 0, 1], [5.6, 0, 0, 0]
];
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_06, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
type Pan = { x: number; y: number; vx: number; vy: number };
const cam: Pan = { x: 120, y: 60, vx: 0, vy: 0 };
const ghost: Pan = { x: 120, y: 60, vx: 0, vy: 0 };
let ghostUntil = -1;
let jumpCam = 0;
let jumpGhost = 0;
function overOf(v: number, max: number): number { return v < 0 ? v : (v > max ? v - max : 0); }
function rubber(o: number): number {
const a = Math.abs(o);
return Math.sign(o) * RUBBER_LIMIT * (1 - 1 / (a / RUBBER_LIMIT + 1));
}
// 화면에 보이는 자리 = 경계 안으로 자른 자리 + 넘은 만큼의 고무줄 몫
function shownX(p: Pan): number { return clamp(p.x, 0, MAX_X) + rubber(overOf(p.x, MAX_X)); }
function shownY(p: Pan): number { return clamp(p.y, 0, MAX_Y) + rubber(overOf(p.y, MAX_Y)); }
function accelOf(p: Pan, ox: number, oy: number): [number, number] {
const ex = overOf(p.x, MAX_X);
const ey = overOf(p.y, MAX_Y);
return [ox * PAN_ACCEL - (ex ? EDGE_STIFFNESS * ex + EDGE_DAMPING * p.vx : 0),
oy * PAN_ACCEL - (ey ? EDGE_STIFFNESS * ey + EDGE_DAMPING * p.vy : 0)];
}
// 고친 판 — 상한으로 자른 뒤 잔걸음으로 나눠, 걸음마다 속도를 먼저 고치고 위치를 옮긴다
function stepPan(p: Pan, ox: number, oy: number, dt: number): void {
let left = Math.min(dt, PAN_DT_CAP);
while (left > 1e-9) {
const h = Math.min(PAN_SUBSTEP, left);
const a = accelOf(p, ox, oy);
const keep = Math.pow(PAN_DAMPING, h);
p.vx = (p.vx + a[0] * h) * keep;
p.vy = (p.vy + a[1] * h) * keep;
p.x += p.vx * h;
p.y += p.vy * h;
left -= h;
}
}
// 깨진 판 — 받은 dt 를 그대로 한 걸음에. 지난 속도로 위치를 먼저 옮기므로 긴 프레임이면 그 속도 × dt 만큼 한 번에 튄다
function naiveStep(p: Pan, ox: number, oy: number, dt: number): void {
const a = accelOf(p, ox, oy);
p.x += p.vx * dt;
p.y += p.vy * dt;
const keep = Math.pow(PAN_DAMPING, dt);
p.vx = (p.vx + a[0] * dt) * keep;
p.vy = (p.vy + a[1] * dt) * keep;
}
function copyPan(to: Pan, from: Pan): void { to.x = from.x; to.y = from.y; to.vx = from.vx; to.vy = from.vy; }
// 평면도 — 시드로 방을 나눈다 [x, y, w, h, 색 번호] 와 가구 [x, y, w, h]
const rooms: number[] = [];
const furniture: number[] = [];
(function (): void {
const r = seeded(66);
function split(x: number, y: number, w: number, h: number, depth: number): void {
if (depth >= 4 || (w < 260 && h < 220)) {
rooms.push(x, y, w, h, Math.floor(r() * 5));
const n = 1 + Math.floor(r() * 3);
for (let k = 0; k < n; k += 1) {
const fw = 26 + r() * 50;
const fh = 20 + r() * 34;
furniture.push(x + 14 + r() * Math.max(1, w - fw - 28), y + 14 + r() * Math.max(1, h - fh - 28), fw, fh);
}
return;
}
const vertical = w / h > 1.1 ? true : (h / w > 1.1 ? false : r() < 0.5);
const k = 0.35 + r() * 0.3;
if (vertical) {
split(x, y, w * k, h, depth + 1);
split(x + w * k, y, w * (1 - k), h, depth + 1);
} else {
split(x, y, w, h * k, depth + 1);
split(x, y + h * k, w, h * (1 - k), depth + 1);
}
}
split(0, 0, WORLD_W, WORLD_H, 0);
})();
let outX = 0;
let outY = 0;
let prevT06 = 0;
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
cam.x = 120; cam.y = 60; cam.vx = 0; cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1; jumpCam = 0; jumpGhost = 0; prevT06 = 0;
}
function update(dt: number): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const o = radialDeadzone(s.rx, s.ry, PAN_DEADZONE);
outX = o[0]; outY = o[1];
if (autoOn && prevT06 < SPIKE_AT && sceneT >= SPIKE_AT) {
// 긴 한 프레임 — 고친 판은 상한으로 잘라 나눠 걷고, 깨진 판은 그대로 한 걸음
copyPan(ghost, cam);
const cx0 = cam.x; const cy0 = cam.y;
stepPan(cam, outX, outY, HIDDEN_FRAME_S);
naiveStep(ghost, outX, outY, HIDDEN_FRAME_S);
jumpCam = Math.hypot(cam.x - cx0, cam.y - cy0);
jumpGhost = Math.hypot(ghost.x - cx0, ghost.y - cy0);
ghostUntil = sceneT + GHOST_SHOW_S;
}
prevT06 = sceneT;
stepPan(cam, outX, outY, dt);
if (sceneT < ghostUntil) { stepPan(ghost, outX, outY, dt); } else { copyPan(ghost, cam); }
}
function restPose(): void {
const s = sticks[0];
s.down = false;
readRaw(s);
snapKnob(s);
cam.x = 240; cam.y = 160; cam.vx = 0; cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1;
}
// 멈춘 화면 — 지금 출력으로 SETTLE_PAN px 옮긴 자리, 경계 밖이면 경계 위
const SETTLE_PAN = 60;
function settle(): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const o = radialDeadzone(s.rx, s.ry, PAN_DEADZONE);
outX = o[0]; outY = o[1];
cam.x = clamp(cam.x + outX * SETTLE_PAN, 0, MAX_X);
cam.y = clamp(cam.y + outY * SETTLE_PAN, 0, MAX_Y);
cam.vx = 0; cam.vy = 0;
copyPan(ghost, cam);
ghostUntil = -1;
}
function mapPointer(x: number, y: number): [number, number, number] {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.2 ? [0, x, y] : [-1, x, y];
}
function statusText(): string { return dirText(outX, outY); }
const ROOM_COLORS: RGB[] = [mix(PAPER, LAVENDER, 0.55), mix(PAPER, MINT, 0.55), mix(PAPER, BUTTER, 0.6), mix(PAPER, SKY, 0.55), mix(PAPER, PINK, 0.55)];
const MAP_X = 346;
const MAP_Y = 198;
const MAP_S = 124 / WORLD_W;
function drawPlan(ox: number, oy: number, k: number, wall: number): void {
for (let i = 0; i < rooms.length; i += 5) {
box(ox + rooms[i] * k, oy + rooms[i + 1] * k, rooms[i + 2] * k, rooms[i + 3] * k, 0, ROOM_COLORS[rooms[i + 4]], 1);
}
for (let i = 0; i < furniture.length; i += 4) {
box(ox + furniture[i] * k, oy + furniture[i + 1] * k, furniture[i + 2] * k, furniture[i + 3] * k, 6 * k, PAPER, 0.85);
}
ctx.lineWidth = wall;
ctx.strokeStyle = rgba(INK, 0.7);
for (let i = 0; i < rooms.length; i += 5) {
ctx.strokeRect(ox + rooms[i] * k, oy + rooms[i + 1] * k, rooms[i + 2] * k, rooms[i + 3] * k);
}
}
function draw(): void {
// 평면도 바깥 — 넘친 자리에 보이는 바탕
fillAll(mix(PAPER_DIM, INK, 0.1));
const vx = shownX(cam);
const vy = shownY(cam);
drawPlan(-vx, -vy, 1, 3);
const diverged = sceneT < ghostUntil;
if (diverged) {
ctx.setLineDash([6, 5]);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.strokeRect(shownX(ghost) - vx + 6, shownY(ghost) - vy + 6, W - 12, H - 12);
ctx.setLineDash([]);
}
// 스틱
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
// 작은 지도 — 지금 보이는 범위(보라)와 숨은 탭 뒤 상한 없이 튄 판(산호)
box(MAP_X - 6, MAP_Y - 6, WORLD_W * MAP_S + 12, WORLD_H * MAP_S + 12, 8, PAPER, 0.95);
drawPlan(MAP_X, MAP_Y, MAP_S, 1);
ctx.lineWidth = 2.5;
ctx.strokeStyle = rgba(VIOLET_DEEP, 1);
ctx.strokeRect(MAP_X + vx * MAP_S, MAP_Y + vy * MAP_S, W * MAP_S, H * MAP_S);
if (diverged) {
ctx.strokeStyle = rgba(CORAL_DEEP, 1);
ctx.strokeRect(MAP_X + shownX(ghost) * MAP_S, MAP_Y + shownY(ghost) * MAP_S, W * MAP_S, H * MAP_S);
tag('숨은 탭 {s}초 한 프레임'.replace('{s}', HIDDEN_FRAME_S.toFixed(1)), MAP_X + 62, MAP_Y - 52, BUTTER, INK, 10, 'center');
tag('상한 없음 · {g}px 튐'.replace('{g}', jumpGhost.toFixed(0)), MAP_X + 62, MAP_Y - 32, CORAL, INK, 10, 'center');
tag('상한 {c}초 · {m}px'.replace('{c}', PAN_DT_CAP.toFixed(2)).replace('{m}', jumpCam.toFixed(0)), MAP_X + 62, MAP_Y - 14, MINT, INK, 10, 'center');
}
const ox = overOf(cam.x, MAX_X);
const oy = overOf(cam.y, MAX_Y);
if (ox || oy) {
tag('넘침 {o}px → 보임 {r}px'.replace('{o}', Math.hypot(ox, oy).toFixed(0)).replace('{r}', Math.hypot(rubber(ox), rubber(oy)).toFixed(0)), 170, 282, PAPER, INK, 10, 'center');
}
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
// 처음 보이는 순간 — 자동 시연을 이 시각만큼 먼저 돌려 둔다
const START_S = 0.9;
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
for (let t = 0; t < START_S; t += 1 / 60) { advance(1 / 60); }
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'스틱으로 평면도를 미끄러지듯 둘러보다 가장자리를 넘으면 고무줄처럼 버티다 돌아오고, 작은 지도에서 숨은 탭 뒤 긴 한 프레임에 튄 판과 상한을 둔 판이 갈린다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 관성으로 둘러보는 평면도와 고무줄 가장자리 — 측정 누적면적 65.642% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function overOf(v, max) { return v < 0 ? v : (v > max ? v - max : 0); }
function rubber(o) {
const a = Math.abs(o);
return Math.sign(o) * RUBBER_LIMIT * (1 - 1 / (a / RUBBER_LIMIT + 1));
}
// 화면에 보이는 자리 = 경계 안으로 자른 자리 + 넘은 만큼의 고무줄 몫
function shownX(p) { return clamp(p.x, 0, MAX_X) + rubber(overOf(p.x, MAX_X)); }
function shownY(p) { return clamp(p.y, 0, MAX_Y) + rubber(overOf(p.y, MAX_Y)); }
function accelOf(p, ox, oy) {
const ex = overOf(p.x, MAX_X);
const ey = overOf(p.y, MAX_Y);
return [ox * PAN_ACCEL - (ex ? EDGE_STIFFNESS * ex + EDGE_DAMPING * p.vx : 0),
oy * PAN_ACCEL - (ey ? EDGE_STIFFNESS * ey + EDGE_DAMPING * p.vy : 0)];
}
07옆으로 미끄러진 만큼 남는 바퀴 자국
주차 연습 시뮬레이터 화면처럼, 스틱 가로 값에 MAX_STEER_DEG 32° 를 곱해 앞바퀴를 꺾고 앞뒤 바퀴 거리 WHEELBASE 30px 로 회전 반경 30 ÷ tan(조향각)을 정해 끝까지 꺾으면 48px, 반만 꺾으면 104.6px 원을 돈다. 머리가 돈 만큼 같은 속도가 옆 속도로 넘어가고, 그 옆 속도는 0.15초마다 TIRE_GRIP 0.85 만큼 깎여 15% 만 남는다. 깎고도 남은 옆 속도가 초당 30px 를 넘는 동안 뒷바퀴 두 점에 자국을 쌓는데, 시연 한 바퀴에서는 0.77초에 첫 자국이 생겨 모두 187.9px 가 남는다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="주차장 위 차가 스틱 가로 값만큼 앞바퀴를 꺾어 점선 회전 원을 따라 돌고, 옆으로 미끄러지는 동안 뒷바퀴 자리에 바퀴 자국이 남았다가 옅어진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 07 옆으로 미끄러진 만큼 남는 바퀴 자국 ────────────────────
// 앞뒤 바퀴 사이 거리(px). 조향각 δ 에서 회전 반경 = WHEELBASE / tan δ, 차가 도는 빠르기 = 앞 속도 / 회전 반경
const WHEELBASE = 30;
const MAX_STEER_DEG = 32;
// 접지력 — GRIP_TIME_S(0.15초)마다 옆으로 미끄러지는 속도에서 TIRE_GRIP 만큼을 깎는다. 작을수록 덜 깎여 더 미끄러진다
const TIRE_GRIP = 0.85;
const GRIP_TIME_S = 0.15;
// 깎고도 남은 옆 속도가 이보다 크면(px/초) 뒷바퀴 자리에 자국을 남긴다
const SKID_MIN = 30;
const ENGINE_ACCEL = 340;
const ROLL_DRAG = 1.2;
const CAR_SUBSTEP = 1 / 240;
const LOOP_S = 6;
const sticks = [makeStick(440, 250, 32)];
const car = { x: 220, y: 80, psi: 0, vx: 150, vy: 0, steer: 0, slip: 0, u: 150 };
// 시연 경로 — 주차장 네 모서리를 차례로 노린다. 가상 엄지는 지금 차 머리와 다음 모서리의 각도 차로 가로 값을 정한다
const CORNERS = [[380, 72], [380, 196], [100, 196], [100, 72]];
let corner = 0;
const AUTO_GAIN = 2.4;
const AUTO_THROTTLE = 0.85;
function autoPose(i, t) {
const s = sticks[i];
if (t < 0.22) {
return { x: s.bx, y: s.by - AUTO_THROTTLE * s.R, down: true };
}
const c = CORNERS[corner];
const want = Math.atan2(c[1] - car.y, c[0] - car.x);
const turn = clamp(wrapAngle(want - car.psi) * AUTO_GAIN, -1, 1);
return { x: s.bx + turn * s.R * 0.9, y: s.by - AUTO_THROTTLE * s.R * 0.9, down: true };
}
// 이번 프레임에 그릴 자국 선분 [x1, y1, x2, y2, 진하기] — 화면 쪽이 받아 그리고 비운다
const skidSegs = [];
let skidTotal = 0;
let resets07 = 0;
function rearWheels(c) {
const fx = Math.cos(c.psi);
const fy = Math.sin(c.psi);
const rx = c.x - fx * WHEELBASE / 2;
const ry = c.y - fy * WHEELBASE / 2;
return [rx - fy * 9, ry + fx * 9, rx + fy * 9, ry - fx * 9];
}
function carStep(c, throttle, h) {
let fx = Math.cos(c.psi);
let fy = Math.sin(c.psi);
let u = c.vx * fx + c.vy * fy;
u += (throttle * ENGINE_ACCEL - u * ROLL_DRAG) * h;
// 앞뒤 바퀴 거리와 조향각으로 구하는 회전 반경 — 그 원을 따라 머리를 돌린다
if (Math.abs(c.steer) > 1e-4) {
c.psi += u * Math.tan(c.steer) / WHEELBASE * h;
}
const w0 = c.vx * -fy + c.vy * fx;
// 머리가 돌았으니 같은 속도를 새 앞·옆 방향으로 다시 나눈다 — 돈 만큼이 옆 속도로 넘어간다
const vx = fx * u + -fy * w0;
const vy = fy * u + fx * w0;
fx = Math.cos(c.psi);
fy = Math.sin(c.psi);
const u2 = vx * fx + vy * fy;
let w = vx * -fy + vy * fx;
w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S);
c.slip = Math.abs(w);
c.u = u2;
c.vx = fx * u2 + -fy * w;
c.vy = fy * u2 + fx * w;
c.x += c.vx * h;
c.y += c.vy * h;
}
function wrapCar(c) {
let moved = false;
if (c.x < 0) {
c.x += W;
moved = true;
}
else if (c.x >= W) {
c.x -= W;
moved = true;
}
if (c.y < 0) {
c.y += H;
moved = true;
}
else if (c.y >= H) {
c.y -= H;
moved = true;
}
return moved;
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
car.x = 220;
car.y = 80;
car.psi = 0;
car.vx = 150;
car.vy = 0;
car.steer = 0;
car.slip = 0;
car.u = 150;
corner = 0;
skidSegs.length = 0;
skidTotal = 0;
resets07 += 1;
}
function update(dt) {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
const throttle = -s.ry;
const before = rearWheels(car);
let left = dt;
while (left > 1e-9) {
const h = Math.min(CAR_SUBSTEP, left);
carStep(car, throttle, h);
left -= h;
}
const jumped = wrapCar(car);
const after = rearWheels(car);
if (!jumped && car.slip > SKID_MIN) {
const a = clamp((car.slip - SKID_MIN) / 80, 0.15, 1);
skidSegs.push(before[0], before[1], after[0], after[1], a, before[2], before[3], after[2], after[3], a);
skidTotal += Math.hypot(after[0] - before[0], after[1] - before[1]);
}
if (autoOn) {
const c = CORNERS[corner];
if (Math.hypot(c[0] - car.x, c[1] - car.y) < 46) {
corner = (corner + 1) % CORNERS.length;
}
}
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.7 * s.R;
s.ty = s.by - 0.6 * s.R;
readRaw(s);
snapKnob(s);
car.x = 300;
car.y = 150;
car.psi = -0.4;
car.vx = 0;
car.vy = 0;
car.slip = 0;
car.u = 0;
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
}
// 멈춘 화면 — 지금 조향으로 회전 원 위를 SETTLE_ARC px 만큼 옮긴 자리, 미끄러짐 없이
const SETTLE_ARC = 24;
function settle() {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
const d = -s.ry * SETTLE_ARC;
if (Math.abs(car.steer) > 1e-4) {
car.psi += d * Math.tan(car.steer) / WHEELBASE;
}
car.x += Math.cos(car.psi) * d;
car.y += Math.sin(car.psi) * d;
car.vx = 0;
car.vy = 0;
car.slip = 0;
wrapCar(car);
}
function mapPointer(x, y) {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.2 ? [0, x, y] : [-1, x, y];
}
function statusText() {
const s = sticks[0];
return dirText(s.rx, s.ry);
}
const ASPHALT = [214, 212, 226];
const SKID_INK = [62, 56, 78];
// 자국을 쌓는 따로 캔버스 — 프레임마다 SKID_FADE 만큼 지워 서서히 옅어진다
const SKID_FADE = 0.006;
const marks = document.createElement('canvas');
marks.width = W * 2;
marks.height = H * 2;
const mctxRaw = marks.getContext('2d');
let seenReset = -1;
// 주차 칸에 서 있는 차 — 시드로 고른 칸
const parked = [];
(function () {
const r = seeded(77);
for (let k = 0; k < 9; k += 1) {
if (r() < 0.5) {
parked.push(150 + k * 26, 4, k % 3);
}
if (r() < 0.5) {
parked.push(150 + k * 26, 268, (k + 1) % 3);
}
}
})();
const PARKED_COLORS = [mix(PAPER, LAVENDER, 0.4), mix(PAPER, MINT, 0.4), mix(PAPER, BUTTER, 0.5)];
function paintSkids() {
if (!mctxRaw) {
skidSegs.length = 0;
return;
}
const m = mctxRaw;
if (seenReset !== resets07) {
seenReset = resets07;
m.clearRect(0, 0, marks.width, marks.height);
}
m.setTransform(2, 0, 0, 2, 0, 0);
m.globalCompositeOperation = 'destination-out';
m.fillStyle = 'rgba(0,0,0,' + SKID_FADE + ')';
m.fillRect(0, 0, W, H);
m.globalCompositeOperation = 'source-over';
m.lineCap = 'round';
m.lineWidth = 5;
for (let k = 0; k < skidSegs.length; k += 5) {
m.strokeStyle = rgba(SKID_INK, 0.8 * skidSegs[k + 4]);
m.beginPath();
m.moveTo(skidSegs[k], skidSegs[k + 1]);
m.lineTo(skidSegs[k + 2], skidSegs[k + 3]);
m.stroke();
}
skidSegs.length = 0;
}
function drawCar(c) {
ctx.save();
ctx.translate(c.x, c.y);
ctx.rotate(c.psi);
box(-22, -8, 50, 22, 8, INK, 0.14);
// 바퀴 — 앞바퀴는 조향각만큼 꺾는다
for (const side of [-1, 1]) {
box(-WHEELBASE / 2 - 5, side * 11 - 3, 10, 6, 2, INK, 0.85);
ctx.save();
ctx.translate(WHEELBASE / 2, side * 11);
ctx.rotate(c.steer);
box(-5, -3, 10, 6, 2, INK, 0.85);
ctx.restore();
}
box(-24, -11, 48, 22, 8, CORAL, 1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.beginPath();
ctx.roundRect(-24, -11, 48, 22, 8);
ctx.stroke();
box(-8, -8, 18, 16, 5, SKY, 0.95);
disc(21, -6, 2.2, BUTTER, 1);
disc(21, 6, 2.2, BUTTER, 1);
ctx.restore();
}
function draw() {
fillAll(ASPHALT);
// 주차 칸 줄
for (let k = 0; k <= 9; k += 1) {
seg(150 + k * 26 - 2, 0, 150 + k * 26 - 2, 34, 2, PAPER, 0.9);
seg(150 + k * 26 - 2, 266, 150 + k * 26 - 2, 300, 2, PAPER, 0.9);
}
for (let k = 0; k < parked.length; k += 3) {
box(parked[k] + 2, parked[k + 1] + 2, 18, 24, 6, PARKED_COLORS[parked[k + 2]], 1);
}
paintSkids();
ctx.drawImage(marks, 0, 0, W, H);
// 회전 원 — 뒷바퀴 축 가운데에서 옆으로 WHEELBASE / tan δ 떨어진 점이 중심
if (Math.abs(car.steer) > 0.03) {
const R = WHEELBASE / Math.tan(Math.abs(car.steer));
const sg = Math.sign(car.steer);
const rx = car.x - Math.cos(car.psi) * WHEELBASE / 2;
const ry = car.y - Math.sin(car.psi) * WHEELBASE / 2;
const cx = rx - Math.sin(car.psi) * sg * R;
const cy = ry + Math.cos(car.psi) * sg * R;
const a0 = Math.atan2(ry - cy, rx - cx);
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.arc(cx, cy, R, a0 - 1.1, a0 + 1.1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.7);
ctx.stroke();
ctx.setLineDash([]);
disc(cx, cy, 3, VIOLET_DEEP, 0.7);
}
drawCar(car);
// 진행 방향(실제 속도)과 머리 방향의 차 — 미끄러짐
const sp = Math.hypot(car.vx, car.vy);
if (sp > 5) {
arrow(car.x, car.y, car.x + car.vx / sp * 40, car.y + car.vy / sp * 40, 2.5, MINT_DEEP, 0.9);
}
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
const R = Math.abs(car.steer) > 0.03 ? (WHEELBASE / Math.tan(Math.abs(car.steer))).toFixed(0) : '∞';
tag('회전 반경 {r}px · 옆 속도 {w}'.replace('{r}', R).replace('{w}', car.slip.toFixed(0)), 12, 282, car.slip > SKID_MIN ? BUTTER : PAPER, INK, 10, 'left');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
// 처음 보이는 순간 — 자동 시연을 이 시각만큼 먼저 돌려 두어 열자마자 바퀴 자국이 보인다
const START_S = 1.3;
function play() {
if (autoOn) {
sceneT = 0;
reset();
for (let t = 0; t < START_S; t += 1 / 60) {
advance(1 / 60);
}
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// CarSlipSkid - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./CarSlipSkid.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CarSlipSkid() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 07 옆으로 미끄러진 만큼 남는 바퀴 자국 ────────────────────
// 앞뒤 바퀴 사이 거리(px). 조향각 δ 에서 회전 반경 = WHEELBASE / tan δ, 차가 도는 빠르기 = 앞 속도 / 회전 반경
const WHEELBASE = 30;
const MAX_STEER_DEG = 32;
// 접지력 — GRIP_TIME_S(0.15초)마다 옆으로 미끄러지는 속도에서 TIRE_GRIP 만큼을 깎는다. 작을수록 덜 깎여 더 미끄러진다
const TIRE_GRIP = 0.85;
const GRIP_TIME_S = 0.15;
// 깎고도 남은 옆 속도가 이보다 크면(px/초) 뒷바퀴 자리에 자국을 남긴다
const SKID_MIN = 30;
const ENGINE_ACCEL = 340;
const ROLL_DRAG = 1.2;
const CAR_SUBSTEP = 1 / 240;
const LOOP_S = 6;
const sticks: Stick[] = [makeStick(440, 250, 32)];
type Car = { x: number; y: number; psi: number; vx: number; vy: number; steer: number; slip: number; u: number };
const car: Car = { x: 220, y: 80, psi: 0, vx: 150, vy: 0, steer: 0, slip: 0, u: 150 };
// 시연 경로 — 주차장 네 모서리를 차례로 노린다. 가상 엄지는 지금 차 머리와 다음 모서리의 각도 차로 가로 값을 정한다
const CORNERS = [[380, 72], [380, 196], [100, 196], [100, 72]];
let corner = 0;
const AUTO_GAIN = 2.4;
const AUTO_THROTTLE = 0.85;
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
if (t < 0.22) { return { x: s.bx, y: s.by - AUTO_THROTTLE * s.R, down: true }; }
const c = CORNERS[corner];
const want = Math.atan2(c[1] - car.y, c[0] - car.x);
const turn = clamp(wrapAngle(want - car.psi) * AUTO_GAIN, -1, 1);
return { x: s.bx + turn * s.R * 0.9, y: s.by - AUTO_THROTTLE * s.R * 0.9, down: true };
}
// 이번 프레임에 그릴 자국 선분 [x1, y1, x2, y2, 진하기] — 화면 쪽이 받아 그리고 비운다
const skidSegs: number[] = [];
let skidTotal = 0;
let resets07 = 0;
function rearWheels(c: Car): [number, number, number, number] {
const fx = Math.cos(c.psi);
const fy = Math.sin(c.psi);
const rx = c.x - fx * WHEELBASE / 2;
const ry = c.y - fy * WHEELBASE / 2;
return [rx - fy * 9, ry + fx * 9, rx + fy * 9, ry - fx * 9];
}
function carStep(c: Car, throttle: number, h: number): void {
let fx = Math.cos(c.psi);
let fy = Math.sin(c.psi);
let u = c.vx * fx + c.vy * fy;
u += (throttle * ENGINE_ACCEL - u * ROLL_DRAG) * h;
// 앞뒤 바퀴 거리와 조향각으로 구하는 회전 반경 — 그 원을 따라 머리를 돌린다
if (Math.abs(c.steer) > 1e-4) { c.psi += u * Math.tan(c.steer) / WHEELBASE * h; }
const w0 = c.vx * -fy + c.vy * fx;
// 머리가 돌았으니 같은 속도를 새 앞·옆 방향으로 다시 나눈다 — 돈 만큼이 옆 속도로 넘어간다
const vx = fx * u + -fy * w0;
const vy = fy * u + fx * w0;
fx = Math.cos(c.psi);
fy = Math.sin(c.psi);
const u2 = vx * fx + vy * fy;
let w = vx * -fy + vy * fx;
w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S);
c.slip = Math.abs(w);
c.u = u2;
c.vx = fx * u2 + -fy * w;
c.vy = fy * u2 + fx * w;
c.x += c.vx * h;
c.y += c.vy * h;
}
function wrapCar(c: Car): boolean {
let moved = false;
if (c.x < 0) { c.x += W; moved = true; } else if (c.x >= W) { c.x -= W; moved = true; }
if (c.y < 0) { c.y += H; moved = true; } else if (c.y >= H) { c.y -= H; moved = true; }
return moved;
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
car.x = 220; car.y = 80; car.psi = 0; car.vx = 150; car.vy = 0; car.steer = 0; car.slip = 0; car.u = 150;
corner = 0;
skidSegs.length = 0;
skidTotal = 0;
resets07 += 1;
}
function update(dt: number): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
const throttle = -s.ry;
const before = rearWheels(car);
let left = dt;
while (left > 1e-9) {
const h = Math.min(CAR_SUBSTEP, left);
carStep(car, throttle, h);
left -= h;
}
const jumped = wrapCar(car);
const after = rearWheels(car);
if (!jumped && car.slip > SKID_MIN) {
const a = clamp((car.slip - SKID_MIN) / 80, 0.15, 1);
skidSegs.push(before[0], before[1], after[0], after[1], a, before[2], before[3], after[2], after[3], a);
skidTotal += Math.hypot(after[0] - before[0], after[1] - before[1]);
}
if (autoOn) {
const c = CORNERS[corner];
if (Math.hypot(c[0] - car.x, c[1] - car.y) < 46) { corner = (corner + 1) % CORNERS.length; }
}
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + 0.7 * s.R;
s.ty = s.by - 0.6 * s.R;
readRaw(s);
snapKnob(s);
car.x = 300; car.y = 150; car.psi = -0.4; car.vx = 0; car.vy = 0; car.slip = 0; car.u = 0;
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
}
// 멈춘 화면 — 지금 조향으로 회전 원 위를 SETTLE_ARC px 만큼 옮긴 자리, 미끄러짐 없이
const SETTLE_ARC = 24;
function settle(): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
car.steer = s.rx * MAX_STEER_DEG * Math.PI / 180;
const d = -s.ry * SETTLE_ARC;
if (Math.abs(car.steer) > 1e-4) { car.psi += d * Math.tan(car.steer) / WHEELBASE; }
car.x += Math.cos(car.psi) * d;
car.y += Math.sin(car.psi) * d;
car.vx = 0; car.vy = 0; car.slip = 0;
wrapCar(car);
}
function mapPointer(x: number, y: number): [number, number, number] {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.2 ? [0, x, y] : [-1, x, y];
}
function statusText(): string {
const s = sticks[0];
return dirText(s.rx, s.ry);
}
const ASPHALT: RGB = [214, 212, 226];
const SKID_INK: RGB = [62, 56, 78];
// 자국을 쌓는 따로 캔버스 — 프레임마다 SKID_FADE 만큼 지워 서서히 옅어진다
const SKID_FADE = 0.006;
const marks = document.createElement('canvas');
marks.width = W * 2;
marks.height = H * 2;
const mctxRaw = marks.getContext('2d');
let seenReset = -1;
// 주차 칸에 서 있는 차 — 시드로 고른 칸
const parked: number[] = [];
(function (): void {
const r = seeded(77);
for (let k = 0; k < 9; k += 1) {
if (r() < 0.5) { parked.push(150 + k * 26, 4, k % 3); }
if (r() < 0.5) { parked.push(150 + k * 26, 268, (k + 1) % 3); }
}
})();
const PARKED_COLORS: RGB[] = [mix(PAPER, LAVENDER, 0.4), mix(PAPER, MINT, 0.4), mix(PAPER, BUTTER, 0.5)];
function paintSkids(): void {
if (!mctxRaw) { skidSegs.length = 0; return; }
const m = mctxRaw;
if (seenReset !== resets07) {
seenReset = resets07;
m.clearRect(0, 0, marks.width, marks.height);
}
m.setTransform(2, 0, 0, 2, 0, 0);
m.globalCompositeOperation = 'destination-out';
m.fillStyle = 'rgba(0,0,0,' + SKID_FADE + ')';
m.fillRect(0, 0, W, H);
m.globalCompositeOperation = 'source-over';
m.lineCap = 'round';
m.lineWidth = 5;
for (let k = 0; k < skidSegs.length; k += 5) {
m.strokeStyle = rgba(SKID_INK, 0.8 * skidSegs[k + 4]);
m.beginPath();
m.moveTo(skidSegs[k], skidSegs[k + 1]);
m.lineTo(skidSegs[k + 2], skidSegs[k + 3]);
m.stroke();
}
skidSegs.length = 0;
}
function drawCar(c: Car): void {
ctx.save();
ctx.translate(c.x, c.y);
ctx.rotate(c.psi);
box(-22, -8, 50, 22, 8, INK, 0.14);
// 바퀴 — 앞바퀴는 조향각만큼 꺾는다
for (const side of [-1, 1]) {
box(-WHEELBASE / 2 - 5, side * 11 - 3, 10, 6, 2, INK, 0.85);
ctx.save();
ctx.translate(WHEELBASE / 2, side * 11);
ctx.rotate(c.steer);
box(-5, -3, 10, 6, 2, INK, 0.85);
ctx.restore();
}
box(-24, -11, 48, 22, 8, CORAL, 1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(CORAL_DEEP, 0.9);
ctx.beginPath();
ctx.roundRect(-24, -11, 48, 22, 8);
ctx.stroke();
box(-8, -8, 18, 16, 5, SKY, 0.95);
disc(21, -6, 2.2, BUTTER, 1);
disc(21, 6, 2.2, BUTTER, 1);
ctx.restore();
}
function draw(): void {
fillAll(ASPHALT);
// 주차 칸 줄
for (let k = 0; k <= 9; k += 1) {
seg(150 + k * 26 - 2, 0, 150 + k * 26 - 2, 34, 2, PAPER, 0.9);
seg(150 + k * 26 - 2, 266, 150 + k * 26 - 2, 300, 2, PAPER, 0.9);
}
for (let k = 0; k < parked.length; k += 3) { box(parked[k] + 2, parked[k + 1] + 2, 18, 24, 6, PARKED_COLORS[parked[k + 2]], 1); }
paintSkids();
ctx.drawImage(marks, 0, 0, W, H);
// 회전 원 — 뒷바퀴 축 가운데에서 옆으로 WHEELBASE / tan δ 떨어진 점이 중심
if (Math.abs(car.steer) > 0.03) {
const R = WHEELBASE / Math.tan(Math.abs(car.steer));
const sg = Math.sign(car.steer);
const rx = car.x - Math.cos(car.psi) * WHEELBASE / 2;
const ry = car.y - Math.sin(car.psi) * WHEELBASE / 2;
const cx = rx - Math.sin(car.psi) * sg * R;
const cy = ry + Math.cos(car.psi) * sg * R;
const a0 = Math.atan2(ry - cy, rx - cx);
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.arc(cx, cy, R, a0 - 1.1, a0 + 1.1);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, 0.7);
ctx.stroke();
ctx.setLineDash([]);
disc(cx, cy, 3, VIOLET_DEEP, 0.7);
}
drawCar(car);
// 진행 방향(실제 속도)과 머리 방향의 차 — 미끄러짐
const sp = Math.hypot(car.vx, car.vy);
if (sp > 5) { arrow(car.x, car.y, car.x + car.vx / sp * 40, car.y + car.vy / sp * 40, 2.5, MINT_DEEP, 0.9); }
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
const R = Math.abs(car.steer) > 0.03 ? (WHEELBASE / Math.tan(Math.abs(car.steer))).toFixed(0) : '∞';
tag('회전 반경 {r}px · 옆 속도 {w}'.replace('{r}', R).replace('{w}', car.slip.toFixed(0)), 12, 282, car.slip > SKID_MIN ? BUTTER : PAPER, INK, 10, 'left');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
// 처음 보이는 순간 — 자동 시연을 이 시각만큼 먼저 돌려 둔다
const START_S = 1.3;
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
for (let t = 0; t < START_S; t += 1 / 60) { advance(1 / 60); }
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'주차장 위 차가 스틱 가로 값만큼 앞바퀴를 꺾어 점선 회전 원을 따라 돌고, 옆으로 미끄러지는 동안 뒷바퀴 자리에 바퀴 자국이 남았다가 옅어진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 옆으로 미끄러진 만큼 남는 바퀴 자국 — 측정 누적면적 13.785% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function carStep(c, throttle, h) {
let fx = Math.cos(c.psi);
let fy = Math.sin(c.psi);
let u = c.vx * fx + c.vy * fy;
u += (throttle * ENGINE_ACCEL - u * ROLL_DRAG) * h;
// 앞뒤 바퀴 거리와 조향각으로 구하는 회전 반경 — 그 원을 따라 머리를 돌린다
if (Math.abs(c.steer) > 1e-4) {
c.psi += u * Math.tan(c.steer) / WHEELBASE * h;
}
const w0 = c.vx * -fy + c.vy * fx;
// 머리가 돌았으니 같은 속도를 새 앞·옆 방향으로 다시 나눈다 — 돈 만큼이 옆 속도로 넘어간다
const vx = fx * u + -fy * w0;
const vy = fy * u + fx * w0;
fx = Math.cos(c.psi);
fy = Math.sin(c.psi);
const u2 = vx * fx + vy * fy;
let w = vx * -fy + vy * fx;
w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S);
c.slip = Math.abs(w);
c.u = u2;
c.vx = fx * u2 + -fy * w;
c.vy = fy * u2 + fx * w;
c.x += c.vx * h;
c.y += c.vy * h;
}
08추력과 회전 관성으로 나는 우주선과 배기 입자
웹 게임 랜딩 페이지 히어로처럼, 스틱 방향과 우주선 머리의 각도 차를 −180~180° 로 감아 머리 170° 에서 목표 −170° 를 받으면 −340° 를 도는 대신 20° 만 돈다. 그 차에 40 을 곱한 회전 힘과 각속도에 9 를 곱한 제동을 함께 걸어 90° 돌리기는 3.5° 넘쳤다가 0.908초에 2° 안으로 자리 잡고, 추력 300 과 저항 1.1 이 맞서 끝 속도는 272.7 에서 더 늘지 않는다. 꼬리 불꽃은 1초에 세기 × 90 개씩 우주선 속도를 물려받아 뒤로 뿌려지고, 늙을수록 커지면서 버터에서 산호·라벤더로 옅어진다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="밤하늘의 우주선이 스틱 방향으로 스프링처럼 돌아 앞으로 밀리고, 꼬리 불꽃 입자가 우주선 속도를 물려받아 뒤로 흩어지며 옅어진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">방향 가운데 · 세기 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 08 추력과 회전 관성으로 나는 우주선과 배기 입자 ─────────────
// 머리 방향을 스틱 방향으로 돌리는 스프링 — 각가속도 = TURN_STIFFNESS × (−π~π 로 감은 각도 차) − TURN_DAMPING × 각속도
const TURN_STIFFNESS = 40;
const TURN_DAMPING = 9;
// 추력 = 스틱 세기 × MAX_THRUST (머리 방향), 저항 = 속도 × SHIP_DRAG — 끝 속도는 MAX_THRUST / SHIP_DRAG
const MAX_THRUST = 300;
const SHIP_DRAG = 1.1;
const SHIP_DEADZONE = 0.12;
// 불꽃 입자 — 우주선 속도를 물려받고 꼬리 쪽으로 EXHAUST_SPEED 의 0.7~1.3배를 더해, 1초에 세기 × EXHAUST_RATE 개
const EXHAUST_SPEED = 120;
const EXHAUST_RATE = 90;
const EXHAUST_LIFE_S = 0.9;
const SHIP_SUBSTEP = 1 / 240;
const LOOP_S = 6;
const sticks = [makeStick(66, 232, 40)];
// 가상 엄지 — 열쇠는 [시각, 각도(도), 세기, 닿음]. 170° 에서 −170°(=190°) 로 넘어가는 구간이 있어 감기 없이는 반대로 340° 를 돈다
const PATH_08 = [
[0, -60, 0, 0], [0.22, -60, 0.2, 1], [0.5, -60, 0.95, 1], [1.0, 20, 0.95, 1], [1.5, 170, 0.9, 1], [1.8, 190, 0.9, 1],
[2.6, 250, 0.7, 1], [3.0, 250, 0, 1], [3.6, 60, 0.95, 1], [4.4, -30, 0.9, 1], [5.2, -120, 0.8, 1], [5.6, -120, 0, 1], [6, -60, 0, 0]
];
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(PATH_08, t);
const a = p.x * Math.PI / 180;
return { x: s.bx + Math.cos(a) * p.y * s.R, y: s.by + Math.sin(a) * p.y * s.R, down: p.down };
}
const ship = { x: 260, y: 170, vx: 0, vy: 0, th: -Math.PI / 2, om: 0, aim: -Math.PI / 2, thrust: 0 };
// 입자 [x, y, vx, vy, 남은 수명] 다섯 칸씩
const sparks = [];
const rng08 = seeded(808);
let spawnCarry = 0;
function shipStep(h, m, dirA) {
if (m > 0) {
ship.aim = dirA;
}
const diff = wrapAngle(ship.aim - ship.th);
ship.om += (TURN_STIFFNESS * diff - TURN_DAMPING * ship.om) * h;
ship.th += ship.om * h;
ship.thrust = m;
const fx = Math.cos(ship.th);
const fy = Math.sin(ship.th);
ship.vx += (fx * m * MAX_THRUST - ship.vx * SHIP_DRAG) * h;
ship.vy += (fy * m * MAX_THRUST - ship.vy * SHIP_DRAG) * h;
ship.x += ship.vx * h;
ship.y += ship.vy * h;
spawnCarry += m * EXHAUST_RATE * h;
while (spawnCarry >= 1) {
spawnCarry -= 1;
const spread = (rng08() - 0.5) * 0.9;
const sp = EXHAUST_SPEED * (0.7 + rng08() * 0.6);
const bx = Math.cos(ship.th + Math.PI + spread);
const by = Math.sin(ship.th + Math.PI + spread);
sparks.push(ship.x - fx * 14, ship.y - fy * 14, ship.vx + bx * sp, ship.vy + by * sp, EXHAUST_LIFE_S * (0.6 + rng08() * 0.4));
}
}
function ageSparks(dt) {
for (let k = 0; k < sparks.length; k += 5) {
sparks[k] += sparks[k + 2] * dt;
sparks[k + 1] += sparks[k + 3] * dt;
sparks[k + 4] -= dt;
}
let w = 0;
for (let k = 0; k < sparks.length; k += 5) {
if (sparks[k + 4] > 0) {
for (let j = 0; j < 5; j += 1) {
sparks[w + j] = sparks[k + j];
}
w += 5;
}
}
sparks.length = w;
}
function wrapShip() {
const M = 20;
if (ship.x < -M) {
ship.x += W + 2 * M;
}
else if (ship.x > W + M) {
ship.x -= W + 2 * M;
}
if (ship.y < -M) {
ship.y += H + 2 * M;
}
else if (ship.y > H + M) {
ship.y -= H + 2 * M;
}
}
function reset() {
const s = sticks[0];
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
ship.x = 260;
ship.y = 170;
ship.vx = 0;
ship.vy = -40;
ship.th = -Math.PI / 2;
ship.om = 0;
ship.aim = -Math.PI / 2;
ship.thrust = 0;
sparks.length = 0;
spawnCarry = 0;
}
function update(dt) {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const o = radialDeadzone(s.rx, s.ry, SHIP_DEADZONE);
const m = Math.hypot(o[0], o[1]);
const a = Math.atan2(o[1], o[0]);
let left = dt;
while (left > 1e-9) {
const h = Math.min(SHIP_SUBSTEP, left);
shipStep(h, m, a);
left -= h;
}
ageSparks(dt);
wrapShip();
}
function restPose() {
const s = sticks[0];
s.down = true;
s.tx = s.bx + Math.cos(-0.5) * 0.8 * s.R;
s.ty = s.by + Math.sin(-0.5) * 0.8 * s.R;
readRaw(s);
snapKnob(s);
ship.x = 270;
ship.y = 150;
ship.th = -0.5;
ship.aim = -0.5;
ship.om = 0;
ship.vx = 0;
ship.vy = 0;
ship.thrust = 0;
sparks.length = 0;
}
// 멈춘 화면 — 머리를 스틱 방향에 바로 맞추고, 세기만큼 SETTLE_MOVE px 앞으로
const SETTLE_MOVE = 30;
function settle() {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const o = radialDeadzone(s.rx, s.ry, SHIP_DEADZONE);
const m = Math.hypot(o[0], o[1]);
if (m > 0) {
ship.th = Math.atan2(o[1], o[0]);
ship.aim = ship.th;
}
ship.om = 0;
ship.vx = 0;
ship.vy = 0;
ship.thrust = 0;
ship.x += Math.cos(ship.th) * m * SETTLE_MOVE;
ship.y += Math.sin(ship.th) * m * SETTLE_MOVE;
sparks.length = 0;
wrapShip();
}
function mapPointer(x, y) {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.4 ? [0, x, y] : [-1, x, y];
}
function statusText() { return dirText(sticks[0].rx, sticks[0].ry); }
const NIGHT = [54, 50, 92];
const NIGHT_LOW = [86, 74, 128];
// 배경 별 — 시드로 뽑은 자리, 꾸밈 한 겹(움직이지 않는다)
const stars = [];
(function () {
const r = seeded(88);
for (let k = 0; k < 70; k += 1) {
stars.push(r() * W, r() * H, 0.6 + r() * 1.6, 0.3 + r() * 0.6);
}
})();
function draw() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(NIGHT, 1));
g.addColorStop(1, rgba(NIGHT_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (let k = 0; k < stars.length; k += 4) {
disc(stars[k], stars[k + 1], stars[k + 2], PAPER, stars[k + 3]);
}
// 불꽃 입자 — 남은 수명만큼 진하고, 늙을수록 커지며 버터 → 산호 → 라벤더
for (let k = 0; k < sparks.length; k += 5) {
const life = clamp(sparks[k + 4] / EXHAUST_LIFE_S, 0, 1);
const c = life > 0.5 ? mix(CORAL, BUTTER, (life - 0.5) * 2) : mix(LAVENDER, CORAL, life * 2);
disc(sparks[k], sparks[k + 1], 2 + (1 - life) * 5, c, 0.85 * life);
}
// 머리 → 목표 방향의 감은 각도 차를 호로
const diff = wrapAngle(ship.aim - ship.th);
if (Math.abs(diff) > 0.02) {
ctx.beginPath();
ctx.arc(ship.x, ship.y, 34, ship.th, ship.th + diff, diff < 0);
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(BUTTER, 0.75);
ctx.stroke();
}
seg(ship.x, ship.y, ship.x + Math.cos(ship.aim) * 46, ship.y + Math.sin(ship.aim) * 46, 1.5, PAPER, 0.5);
// 우주선
ctx.save();
ctx.translate(ship.x, ship.y);
ctx.rotate(ship.th);
ctx.beginPath();
ctx.moveTo(18, 0);
ctx.lineTo(-12, 11);
ctx.lineTo(-6, 0);
ctx.lineTo(-12, -11);
ctx.closePath();
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.stroke();
disc(4, 0, 3.5, SKY, 1);
ctx.restore();
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('각도 차 {a}° · 빠르기 {v}'.replace('{a}', toDeg(diff).toFixed(0)).replace('{v}', Math.hypot(ship.vx, ship.vy).toFixed(0)), 468, 282, PAPER, INK, 10, 'right');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ShipThrustTurn - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ShipThrustTurn.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ShipThrustTurn() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 08 추력과 회전 관성으로 나는 우주선과 배기 입자 ─────────────
// 머리 방향을 스틱 방향으로 돌리는 스프링 — 각가속도 = TURN_STIFFNESS × (−π~π 로 감은 각도 차) − TURN_DAMPING × 각속도
const TURN_STIFFNESS = 40;
const TURN_DAMPING = 9;
// 추력 = 스틱 세기 × MAX_THRUST (머리 방향), 저항 = 속도 × SHIP_DRAG — 끝 속도는 MAX_THRUST / SHIP_DRAG
const MAX_THRUST = 300;
const SHIP_DRAG = 1.1;
const SHIP_DEADZONE = 0.12;
// 불꽃 입자 — 우주선 속도를 물려받고 꼬리 쪽으로 EXHAUST_SPEED 의 0.7~1.3배를 더해, 1초에 세기 × EXHAUST_RATE 개
const EXHAUST_SPEED = 120;
const EXHAUST_RATE = 90;
const EXHAUST_LIFE_S = 0.9;
const SHIP_SUBSTEP = 1 / 240;
const LOOP_S = 6;
const sticks: Stick[] = [makeStick(66, 232, 40)];
// 가상 엄지 — 열쇠는 [시각, 각도(도), 세기, 닿음]. 170° 에서 −170°(=190°) 로 넘어가는 구간이 있어 감기 없이는 반대로 340° 를 돈다
const PATH_08: PathKey[] = [
[0, -60, 0, 0], [0.22, -60, 0.2, 1], [0.5, -60, 0.95, 1], [1.0, 20, 0.95, 1], [1.5, 170, 0.9, 1], [1.8, 190, 0.9, 1],
[2.6, 250, 0.7, 1], [3.0, 250, 0, 1], [3.6, 60, 0.95, 1], [4.4, -30, 0.9, 1], [5.2, -120, 0.8, 1], [5.6, -120, 0, 1], [6, -60, 0, 0]
];
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(PATH_08, t);
const a = p.x * Math.PI / 180;
return { x: s.bx + Math.cos(a) * p.y * s.R, y: s.by + Math.sin(a) * p.y * s.R, down: p.down };
}
type Ship = { x: number; y: number; vx: number; vy: number; th: number; om: number; aim: number; thrust: number };
const ship: Ship = { x: 260, y: 170, vx: 0, vy: 0, th: -Math.PI / 2, om: 0, aim: -Math.PI / 2, thrust: 0 };
// 입자 [x, y, vx, vy, 남은 수명] 다섯 칸씩
const sparks: number[] = [];
const rng08 = seeded(808);
let spawnCarry = 0;
function shipStep(h: number, m: number, dirA: number): void {
if (m > 0) { ship.aim = dirA; }
const diff = wrapAngle(ship.aim - ship.th);
ship.om += (TURN_STIFFNESS * diff - TURN_DAMPING * ship.om) * h;
ship.th += ship.om * h;
ship.thrust = m;
const fx = Math.cos(ship.th);
const fy = Math.sin(ship.th);
ship.vx += (fx * m * MAX_THRUST - ship.vx * SHIP_DRAG) * h;
ship.vy += (fy * m * MAX_THRUST - ship.vy * SHIP_DRAG) * h;
ship.x += ship.vx * h;
ship.y += ship.vy * h;
spawnCarry += m * EXHAUST_RATE * h;
while (spawnCarry >= 1) {
spawnCarry -= 1;
const spread = (rng08() - 0.5) * 0.9;
const sp = EXHAUST_SPEED * (0.7 + rng08() * 0.6);
const bx = Math.cos(ship.th + Math.PI + spread);
const by = Math.sin(ship.th + Math.PI + spread);
sparks.push(ship.x - fx * 14, ship.y - fy * 14, ship.vx + bx * sp, ship.vy + by * sp, EXHAUST_LIFE_S * (0.6 + rng08() * 0.4));
}
}
function ageSparks(dt: number): void {
for (let k = 0; k < sparks.length; k += 5) {
sparks[k] += sparks[k + 2] * dt;
sparks[k + 1] += sparks[k + 3] * dt;
sparks[k + 4] -= dt;
}
let w = 0;
for (let k = 0; k < sparks.length; k += 5) {
if (sparks[k + 4] > 0) {
for (let j = 0; j < 5; j += 1) { sparks[w + j] = sparks[k + j]; }
w += 5;
}
}
sparks.length = w;
}
function wrapShip(): void {
const M = 20;
if (ship.x < -M) { ship.x += W + 2 * M; } else if (ship.x > W + M) { ship.x -= W + 2 * M; }
if (ship.y < -M) { ship.y += H + 2 * M; } else if (ship.y > H + M) { ship.y -= H + 2 * M; }
}
function reset(): void {
const s = sticks[0];
s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false;
ship.x = 260; ship.y = 170; ship.vx = 0; ship.vy = -40; ship.th = -Math.PI / 2; ship.om = 0; ship.aim = -Math.PI / 2; ship.thrust = 0;
sparks.length = 0;
spawnCarry = 0;
}
function update(dt: number): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
followKnob(s, dt);
const o = radialDeadzone(s.rx, s.ry, SHIP_DEADZONE);
const m = Math.hypot(o[0], o[1]);
const a = Math.atan2(o[1], o[0]);
let left = dt;
while (left > 1e-9) {
const h = Math.min(SHIP_SUBSTEP, left);
shipStep(h, m, a);
left -= h;
}
ageSparks(dt);
wrapShip();
}
function restPose(): void {
const s = sticks[0];
s.down = true;
s.tx = s.bx + Math.cos(-0.5) * 0.8 * s.R;
s.ty = s.by + Math.sin(-0.5) * 0.8 * s.R;
readRaw(s);
snapKnob(s);
ship.x = 270; ship.y = 150; ship.th = -0.5; ship.aim = -0.5; ship.om = 0; ship.vx = 0; ship.vy = 0; ship.thrust = 0;
sparks.length = 0;
}
// 멈춘 화면 — 머리를 스틱 방향에 바로 맞추고, 세기만큼 SETTLE_MOVE px 앞으로
const SETTLE_MOVE = 30;
function settle(): void {
const s = sticks[0];
driveSticks(sticks, autoPose);
snapKnob(s);
const o = radialDeadzone(s.rx, s.ry, SHIP_DEADZONE);
const m = Math.hypot(o[0], o[1]);
if (m > 0) { ship.th = Math.atan2(o[1], o[0]); ship.aim = ship.th; }
ship.om = 0; ship.vx = 0; ship.vy = 0; ship.thrust = 0;
ship.x += Math.cos(ship.th) * m * SETTLE_MOVE;
ship.y += Math.sin(ship.th) * m * SETTLE_MOVE;
sparks.length = 0;
wrapShip();
}
function mapPointer(x: number, y: number): [number, number, number] {
const s = sticks[0];
return Math.hypot(x - s.bx, y - s.by) < s.R * 2.4 ? [0, x, y] : [-1, x, y];
}
function statusText(): string { return dirText(sticks[0].rx, sticks[0].ry); }
const NIGHT: RGB = [54, 50, 92];
const NIGHT_LOW: RGB = [86, 74, 128];
// 배경 별 — 시드로 뽑은 자리, 꾸밈 한 겹(움직이지 않는다)
const stars: number[] = [];
(function (): void {
const r = seeded(88);
for (let k = 0; k < 70; k += 1) { stars.push(r() * W, r() * H, 0.6 + r() * 1.6, 0.3 + r() * 0.6); }
})();
function draw(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(NIGHT, 1));
g.addColorStop(1, rgba(NIGHT_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
for (let k = 0; k < stars.length; k += 4) { disc(stars[k], stars[k + 1], stars[k + 2], PAPER, stars[k + 3]); }
// 불꽃 입자 — 남은 수명만큼 진하고, 늙을수록 커지며 버터 → 산호 → 라벤더
for (let k = 0; k < sparks.length; k += 5) {
const life = clamp(sparks[k + 4] / EXHAUST_LIFE_S, 0, 1);
const c = life > 0.5 ? mix(CORAL, BUTTER, (life - 0.5) * 2) : mix(LAVENDER, CORAL, life * 2);
disc(sparks[k], sparks[k + 1], 2 + (1 - life) * 5, c, 0.85 * life);
}
// 머리 → 목표 방향의 감은 각도 차를 호로
const diff = wrapAngle(ship.aim - ship.th);
if (Math.abs(diff) > 0.02) {
ctx.beginPath();
ctx.arc(ship.x, ship.y, 34, ship.th, ship.th + diff, diff < 0);
ctx.lineWidth = 4;
ctx.strokeStyle = rgba(BUTTER, 0.75);
ctx.stroke();
}
seg(ship.x, ship.y, ship.x + Math.cos(ship.aim) * 46, ship.y + Math.sin(ship.aim) * 46, 1.5, PAPER, 0.5);
// 우주선
ctx.save();
ctx.translate(ship.x, ship.y);
ctx.rotate(ship.th);
ctx.beginPath();
ctx.moveTo(18, 0);
ctx.lineTo(-12, 11);
ctx.lineTo(-6, 0);
ctx.lineTo(-12, -11);
ctx.closePath();
ctx.fillStyle = rgba(LAVENDER, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(PAPER, 0.9);
ctx.stroke();
disc(4, 0, 3.5, SKY, 1);
ctx.restore();
const s = sticks[0];
drawBase(s.bx, s.by, s.R, 1);
drawKnob(s, LAVENDER, 1);
drawThumb(s.tx, s.ty, s.down);
tag('각도 차 {a}° · 빠르기 {v}'.replace('{a}', toDeg(diff).toFixed(0)).replace('{v}', Math.hypot(ship.vx, ship.vy).toFixed(0)), 468, 282, PAPER, INK, 10, 'right');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'밤하늘의 우주선이 스틱 방향으로 스프링처럼 돌아 앞으로 밀리고, 꼬리 불꽃 입자가 우주선 속도를 물려받아 뒤로 흩어지며 옅어진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'방향 가운데 · 세기 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 추력과 회전 관성으로 나는 우주선과 배기 입자 — 측정 누적면적 21.767% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function shipStep(h, m, dirA) {
if (m > 0) {
ship.aim = dirA;
}
const diff = wrapAngle(ship.aim - ship.th);
ship.om += (TURN_STIFFNESS * diff - TURN_DAMPING * ship.om) * h;
ship.th += ship.om * h;
ship.thrust = m;
const fx = Math.cos(ship.th);
const fy = Math.sin(ship.th);
ship.vx += (fx * m * MAX_THRUST - ship.vx * SHIP_DRAG) * h;
ship.vy += (fy * m * MAX_THRUST - ship.vy * SHIP_DRAG) * h;
ship.x += ship.vx * h;
ship.y += ship.vy * h;
spawnCarry += m * EXHAUST_RATE * h;
while (spawnCarry >= 1) {
spawnCarry -= 1;
const spread = (rng08() - 0.5) * 0.9;
const sp = EXHAUST_SPEED * (0.7 + rng08() * 0.6);
const bx = Math.cos(ship.th + Math.PI + spread);
const by = Math.sin(ship.th + Math.PI + spread);
sparks.push(ship.x - fx * 14, ship.y - fy * 14, ship.vx + bx * sp, ship.vy + by * sp, EXHAUST_LIFE_S * (0.6 + rng08() * 0.4));
}
}
09두 스틱으로 나는 드론 시점 장면
드론 촬영 서비스 랜딩 히어로처럼, 왼쪽 스틱은 앞뒤·좌우 이동을, 오른쪽 스틱은 방향 돌기와 고도 3~24 를 맡는다. 물체의 화면 자리는 초점 거리 250 × 옆 거리 ÷ 깊이로 정해 높이 10 인 물체가 깊이 10 에서 250px, 20 에서 125px 로 줄고, 안개는 1 − e^(−깊이 × 0.011) 이라 깊이 63 에서 절반을 덮는다. 깊이 2 안쪽 점은 그리지 않고, 나무와 건물 상자는 먼 것부터 그려 가까운 것이 위에 덮이게 한다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기 · Shift 와 함께 누르면 오른쪽 스틱">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="두 스틱이 정해진 비행 경로로 움직여 바닥 격자와 나무·건물 상자 사이를 나는 드론 시점 장면을 먼 것부터 그리고, 멀수록 안개가 짙어진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">왼쪽 가운데 0% · 오른쪽 가운데 0%</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 스틱이 가져간다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 스틱 방향·세기를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a) {
let r = (a + Math.PI) % TAU;
if (r < 0) {
r += TAU;
}
return r - Math.PI;
}
function toDeg(rad) { return rad * 180 / Math.PI; }
function makeStick(bx, by, R) {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s, dt) {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s) { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x, y) {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x, y) {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) {
return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0');
}
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
function pathAt(keys, t) {
if (t <= keys[0][0]) {
return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 };
}
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed) {
const r = seeded(seed);
const f = [];
const p = [];
for (let k = 0; k < 6; k += 1) {
f.push(5 + r() * 9);
p.push(r() * TAU);
}
return function (t, amp) {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX = [0, 0];
const keyY = [0, 0];
const keyOn = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list, auto) {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x;
s.ty = p.y;
s.down = p.down;
}
else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R;
s.ty = s.by + keyY[i] * s.R;
s.down = true;
}
else {
s.tx = s.hx;
s.ty = s.hy;
s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER = [250, 246, 240];
const PAPER_DIM = [234, 228, 220];
const INK = [40, 36, 54];
const INK_SOFT = [110, 102, 124];
const LAVENDER = [196, 184, 240];
const MINT = [166, 220, 200];
const BUTTER = [247, 226, 156];
const CORAL = [244, 174, 158];
const SKY = [180, 212, 240];
const PINK = [244, 196, 214];
const VIOLET_DEEP = [86, 62, 160];
const MINT_DEEP = [32, 110, 86];
const CORAL_DEEP = [176, 66, 54];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1, y1, x2, y2, width, c, a) {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) {
return;
}
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x, y, R, a) {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s, c, a) {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x, y, on) {
if (!on) {
return;
}
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 09 두 스틱으로 나는 드론 시점 장면 ─────────────────────────
// 투영 — 화면 x = 가운데 + FOCAL_LENGTH × 옆 거리 / 깊이, 화면 y = 지평선 + FOCAL_LENGTH × (고도 − 높이) / 깊이
const FOCAL_LENGTH = 250;
// 깊이가 NEAR_Z 보다 가까운(또는 뒤에 있는) 점은 그리지 않는다 — 0 으로 나누기와 뒤집힌 그림을 막는다
const NEAR_Z = 2;
const FAR_Z = 260;
// 안개 — 깊이 z 에서 안개 몫 = 1 − e^(−z × FOG_DENSITY)
const FOG_DENSITY = 0.011;
const HORIZON_Y = 112;
// 왼쪽 스틱: 앞뒤 FLY_SPEED · 좌우 STRAFE_SPEED, 오른쪽 스틱: 돌기 YAW_RATE(라디안/초) · 오르내리기 CLIMB_RATE
const FLY_SPEED = 28;
const STRAFE_SPEED = 18;
const YAW_RATE = 1.1;
const CLIMB_RATE = 9;
// 스틱 값이 바뀌어도 속도는 이 빠르기로 따라간다 — 드론의 무게
const DRONE_RESPONSE = 3.5;
const DRONE_DEADZONE = 0.1;
const ALT_MIN = 3;
const ALT_MAX = 24;
// 세상 한 칸(이 크기로 같은 배치가 되풀이된다)
const CELL = 120;
const LOOP_S = 8;
const sticks = [makeStick(60, 244, 34), makeStick(420, 244, 34)];
const PATH_09L = [
[0, 0, 0, 0], [0.22, 0, -0.1, 1], [0.6, 0.15, -0.85, 1], [2.0, -0.25, -0.8, 1], [3.4, 0.3, -0.9, 1], [4.8, -0.2, -0.7, 1],
[6.2, 0.1, -0.85, 1], [7.5, 0, -0.5, 1], [7.8, 0, 0, 1], [8, 0, 0, 0]
];
const PATH_09R = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.7, 0.55, -0.3, 1], [1.8, 0.6, 0.2, 1], [2.8, -0.5, 0.1, 1], [4.2, -0.4, -0.4, 1],
[5.4, 0.5, 0.35, 1], [6.8, 0.2, -0.2, 1], [7.6, 0, 0, 1], [8, 0, 0, 0]
];
function autoPose(i, t) {
const s = sticks[i];
const p = pathAt(i === 0 ? PATH_09L : PATH_09R, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
const drone = { x: 0, z: 0, yaw: 0, alt: 13, vf: 0, vs: 0, vyaw: 0, valt: 0 };
// 한 칸 안의 배치 — 나무 [x, z, 높이, 색] · 건물 상자 [x, z, 폭, 깊이, 높이, 색]
const trees = [];
const blocks = [];
(function () {
const r = seeded(909);
for (let k = 0; k < 7; k += 1) {
trees.push(r() * CELL, r() * CELL, 7 + r() * 6, Math.floor(r() * 2));
}
for (let k = 0; k < 3; k += 1) {
blocks.push(r() * CELL, r() * CELL, 9 + r() * 10, 9 + r() * 10, 6 + r() * 14, Math.floor(r() * 3));
}
})();
function fogOf(z) { return 1 - Math.exp(-z * FOG_DENSITY); }
// 점 하나를 화면으로 — [화면 x, 화면 y, 깊이] 또는 근평면 앞이면 null
function project(X, Y, Z) {
const dx = X - drone.x;
const dz = Z - drone.z;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
const side = dx * c - dz * s;
const depth = dx * s + dz * c;
if (depth <= NEAR_Z) {
return null;
}
return [W / 2 + FOCAL_LENGTH * side / depth, HORIZON_Y + FOCAL_LENGTH * (drone.alt - Y) / depth, depth];
}
function depthOf(X, Z) {
return (X - drone.x) * Math.sin(drone.yaw) + (Z - drone.z) * Math.cos(drone.yaw);
}
let outL = [0, 0];
let outR = [0, 0];
function flyStep(dt) {
const f = follow(DRONE_RESPONSE, dt);
drone.vf += (-outL[1] * FLY_SPEED - drone.vf) * f;
drone.vs += (outL[0] * STRAFE_SPEED - drone.vs) * f;
drone.vyaw += (outR[0] * YAW_RATE - drone.vyaw) * f;
drone.valt += (-outR[1] * CLIMB_RATE - drone.valt) * f;
drone.yaw += drone.vyaw * dt;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
drone.x += (s * drone.vf + c * drone.vs) * dt;
drone.z += (c * drone.vf - s * drone.vs) * dt;
drone.alt = clamp(drone.alt + drone.valt * dt, ALT_MIN, ALT_MAX);
}
function reset() {
for (const s of sticks) {
s.kx = 0;
s.ky = 0;
s.rx = 0;
s.ry = 0;
s.down = false;
}
drone.x = 0;
drone.z = 0;
drone.yaw = 0;
drone.alt = 13;
drone.vf = 0;
drone.vs = 0;
drone.vyaw = 0;
drone.valt = 0;
outL = [0, 0];
outR = [0, 0];
}
function update(dt) {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
followKnob(sticks[1], dt);
outL = radialDeadzone(sticks[0].rx, sticks[0].ry, DRONE_DEADZONE);
outR = radialDeadzone(sticks[1].rx, sticks[1].ry, DRONE_DEADZONE);
flyStep(dt);
}
function restPose() {
for (const s of sticks) {
s.down = false;
readRaw(s);
snapKnob(s);
}
drone.x = 30;
drone.z = 10;
drone.yaw = 0.5;
drone.alt = 10;
drone.vf = 0;
drone.vs = 0;
drone.vyaw = 0;
drone.valt = 0;
outL = [0, 0];
outR = [0, 0];
}
// 멈춘 화면 — 지금 스틱 값으로 SETTLE_FLY_S 초 날아간 자리에 바로(속도는 남기지 않는다)
const SETTLE_FLY_S = 0.4;
function settle() {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
snapKnob(sticks[1]);
outL = radialDeadzone(sticks[0].rx, sticks[0].ry, DRONE_DEADZONE);
outR = radialDeadzone(sticks[1].rx, sticks[1].ry, DRONE_DEADZONE);
drone.vf = -outL[1] * FLY_SPEED;
drone.vs = outL[0] * STRAFE_SPEED;
drone.vyaw = outR[0] * YAW_RATE;
drone.valt = -outR[1] * CLIMB_RATE;
drone.yaw += drone.vyaw * SETTLE_FLY_S;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
drone.x += (s * drone.vf + c * drone.vs) * SETTLE_FLY_S;
drone.z += (c * drone.vf - s * drone.vs) * SETTLE_FLY_S;
drone.alt = clamp(drone.alt + drone.valt * SETTLE_FLY_S, ALT_MIN, ALT_MAX);
drone.vf = 0;
drone.vs = 0;
drone.vyaw = 0;
drone.valt = 0;
}
function mapPointer(x, y) { return [x < W / 2 ? 0 : 1, x, y]; }
function stickWord(o) {
const pct = Math.round(clamp(Math.hypot(o[0], o[1]), 0, 1) * 20) * 5;
return [pct === 0 ? CENTER_NAME : DIR_NAMES[sectorOf(o[0], o[1])], String(pct)];
}
function statusText() {
const a = stickWord([sticks[0].rx, sticks[0].ry]);
const b = stickWord([sticks[1].rx, sticks[1].ry]);
return '왼쪽 {d} {s}% · 오른쪽 {e} {t}%'.replace('{d}', a[0]).replace('{s}', a[1]).replace('{e}', b[0]).replace('{t}', b[1]);
}
const SKY_TOP = [176, 204, 238];
const FOG_COLOR = [232, 230, 236];
const GROUND_NEAR = [170, 214, 186];
const GRID_STEP = 20;
const NEAR_FADE_Z = 14;
const NEAR_FADE_LEN = 22;
const TREE_COLORS = [[132, 196, 160], [160, 206, 150]];
const BLOCK_COLORS = [mix(PAPER, LAVENDER, 0.35), mix(PAPER, PINK, 0.3), mix(PAPER, BUTTER, 0.35)];
// 안개 섞기 — 깊이가 멀수록 물체 색을 안개색으로
function fogged(c, z) { return mix(c, FOG_COLOR, fogOf(z)); }
// 바닥 격자 선 하나 — 짧은 토막으로 잘라 토막마다 깊이 안개, 근평면 앞 토막은 근평면에서 잘라 낸다
function groundLine(x1, z1, x2, z2) {
const N = 10;
for (let k = 0; k < N; k += 1) {
let ax = lerp(x1, x2, k / N);
let az = lerp(z1, z2, k / N);
let bx = lerp(x1, x2, (k + 1) / N);
let bz = lerp(z1, z2, (k + 1) / N);
const da = depthOf(ax, az);
const db = depthOf(bx, bz);
if (da <= NEAR_Z && db <= NEAR_Z) {
continue;
}
if (da <= NEAR_Z || db <= NEAR_Z) {
const u = (NEAR_Z + 0.01 - da) / (db - da);
if (da <= NEAR_Z) {
ax = lerp(ax, bx, u);
az = lerp(az, bz, u);
}
else {
bx = lerp(ax, bx, u);
bz = lerp(az, bz, u);
}
}
const pa = project(ax, 0, az);
const pb = project(bx, 0, bz);
if (!pa || !pb || pa[2] > FAR_Z) {
continue;
}
seg(pa[0], pa[1], pb[0], pb[1], 1.6, INK, 0.4 * (1 - fogOf((pa[2] + pb[2]) / 2)));
}
}
const LIGHT_X = 0.5;
const LIGHT_Z = -0.8;
function polygon(pts, c) {
for (const p of pts) {
if (!p) {
return false;
}
}
ctx.beginPath();
for (let k = 0; k < pts.length; k += 1) {
const p = pts[k];
if (k === 0) {
ctx.moveTo(p[0], p[1]);
}
else {
ctx.lineTo(p[0], p[1]);
}
}
ctx.closePath();
ctx.fillStyle = rgba(c, 1);
ctx.fill();
return true;
}
function drawBlock(b, ox, oz, z) {
const cx = blocks[b] + ox;
const cz = blocks[b + 1] + oz;
const hw = blocks[b + 2] / 2;
const hd = blocks[b + 3] / 2;
const h = blocks[b + 4];
const base = BLOCK_COLORS[blocks[b + 5]];
// 옆면 넷 — 바깥쪽 법선이 드론 쪽을 향하는 면만
const faces = [
[cx - hw, cz - hd, cx + hw, cz - hd, 0, -1], [cx + hw, cz - hd, cx + hw, cz + hd, 1, 0],
[cx + hw, cz + hd, cx - hw, cz + hd, 0, 1], [cx - hw, cz + hd, cx - hw, cz - hd, -1, 0]
];
for (const f of faces) {
const mx = (f[0] + f[2]) / 2;
const mz = (f[1] + f[3]) / 2;
if ((drone.x - mx) * f[4] + (drone.z - mz) * f[5] <= 0) {
continue;
}
const lit = 0.72 + 0.28 * clamp(f[4] * LIGHT_X + f[5] * LIGHT_Z, -1, 1);
const c = fogged([base[0] * lit, base[1] * lit, base[2] * lit], z);
polygon([project(f[0], 0, f[1]), project(f[2], 0, f[3]), project(f[2], h, f[3]), project(f[0], h, f[1])], c);
}
if (drone.alt > h) {
polygon([project(cx - hw, h, cz - hd), project(cx + hw, h, cz - hd), project(cx + hw, h, cz + hd), project(cx - hw, h, cz + hd)], fogged(mix(base, PAPER, 0.3), z));
}
}
function drawTree(t, ox, oz) {
const x = trees[t] + ox;
const z = trees[t + 1] + oz;
const h = trees[t + 2];
const a = project(x, 0, z);
const b = project(x, h * 0.35, z);
const top = project(x, h, z);
if (!a || !b || !top) {
return;
}
const r = FOCAL_LENGTH * h * 0.28 / a[2];
seg(a[0], a[1], b[0], b[1], Math.max(1, r * 0.25), fogged([150, 120, 104], a[2]), 1);
ctx.beginPath();
ctx.moveTo(top[0], top[1]);
ctx.lineTo(b[0] + r, b[1]);
ctx.lineTo(b[0] - r, b[1]);
ctx.closePath();
ctx.fillStyle = rgba(fogged(TREE_COLORS[trees[t + 3]], a[2]), 1);
ctx.fill();
}
let drawnCount = 0;
function draw() {
// 하늘과 땅 — 지평선 쪽은 안개색으로 녹는다
const sky = ctx.createLinearGradient(0, 0, 0, HORIZON_Y);
sky.addColorStop(0, rgba(SKY_TOP, 1));
sky.addColorStop(1, rgba(FOG_COLOR, 1));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, HORIZON_Y + 1);
const ground = ctx.createLinearGradient(0, HORIZON_Y, 0, H);
ground.addColorStop(0, rgba(FOG_COLOR, 1));
ground.addColorStop(Math.min(0.9, 0.12 + drone.alt / 40), rgba(fogged(GROUND_NEAR, 60), 1));
ground.addColorStop(1, rgba(GROUND_NEAR, 1));
ctx.fillStyle = ground;
ctx.fillRect(0, HORIZON_Y, W, H - HORIZON_Y);
const gx0 = Math.floor((drone.x - FAR_Z) / GRID_STEP) * GRID_STEP;
const gz0 = Math.floor((drone.z - FAR_Z) / GRID_STEP) * GRID_STEP;
for (let g = 0; g <= (FAR_Z * 2) / GRID_STEP; g += 1) {
const xx = gx0 + g * GRID_STEP;
const zz = gz0 + g * GRID_STEP;
if (Math.abs(xx - drone.x) < 160) {
groundLine(xx, drone.z - 160, xx, drone.z + 160);
}
if (Math.abs(zz - drone.z) < 160) {
groundLine(drone.x - 160, zz, drone.x + 160, zz);
}
}
// 물체 모으기 — 드론 둘레 칸들의 나무·상자, 깊이를 재서 먼 것부터
const items = [];
const ci = Math.floor(drone.x / CELL);
const cj = Math.floor(drone.z / CELL);
for (let i = ci - 2; i <= ci + 2; i += 1) {
for (let j = cj - 2; j <= cj + 2; j += 1) {
const ox = i * CELL;
const oz = j * CELL;
for (let t = 0; t < trees.length; t += 4) {
const z = depthOf(trees[t] + ox, trees[t + 1] + oz);
if (z > NEAR_Z && z < FAR_Z) {
items.push({ z: z, kind: 0, i: t, ox: ox, oz: oz });
}
}
for (let b = 0; b < blocks.length; b += 6) {
const z = depthOf(blocks[b] + ox, blocks[b + 1] + oz);
if (z > NEAR_Z + 4 && z < FAR_Z) {
items.push({ z: z, kind: 1, i: b, ox: ox, oz: oz });
}
}
}
}
items.sort(function (a, b) { return b.z - a.z; });
for (const it of items) {
// 가까운 물체는 깊이 NEAR_FADE_Z + NEAR_FADE_LEN 안쪽부터 옅어져 NEAR_FADE_Z 에서 사라진다 — 드론이 지나칠 때 화면을 통째로 가리지 않게
ctx.globalAlpha = clamp((it.z - NEAR_FADE_Z) / NEAR_FADE_LEN, 0, 1);
if (it.kind === 0) {
drawTree(it.i, it.ox, it.oz);
}
else {
drawBlock(it.i, it.ox, it.oz, it.z);
}
}
ctx.globalAlpha = 1;
drawnCount = items.length;
for (let k = 0; k < sticks.length; k += 1) {
const s = sticks[k];
drawBase(s.bx, s.by, s.R, 0.85);
drawKnob(s, k === 0 ? MINT : LAVENDER, 0.95);
drawThumb(s.tx, s.ty, s.down);
}
tag('먼 것부터 {n}개 · 고도 {a}'.replace('{n}', String(drawnCount)).replace('{a}', drone.alt.toFixed(1)), W / 2, 282, PAPER, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held = {};
let keyStick = 0;
function keyTarget() {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) {
x /= m;
y /= m;
}
return [x, y];
}
function anyHeld() { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt) {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) {
keyX[i] = tx;
keyY[i] = ty;
}
else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) {
keyX[i] = 0;
keyY[i] = 0;
}
}
}
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus(statusText());
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e) {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) {
held[k] = false;
}
keyStick = want;
}
held[e.key] = true;
if (still || paused) {
rampKeys(0);
}
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if (!ARROWS[e.key]) {
return;
}
held[e.key] = false;
if (still || paused) {
rampKeys(0);
showNow();
}
}, { signal: off.signal });
pad.addEventListener('blur', function () {
for (const k of Object.keys(held)) {
held[k] = false;
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed = {};
function toStage(e) {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e) {
const i = grabbed[String(e.pointerId)];
if (i === undefined) {
return;
}
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DualStickDroneView - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DualStickDroneView.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DualStickDroneView() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 몫 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초 동안 다가가는 양이 같다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 각도 차를 −π~π 로 감는다 — 350° 와 10° 의 차는 −340° 가 아니라 20°
function wrapAngle(a: number): number {
let r = (a + Math.PI) % TAU;
if (r < 0) { r += TAU; }
return r - Math.PI;
}
function toDeg(rad: number): number { return rad * 180 / Math.PI; }
// ── 스틱 ─────────────────────────────────────────────────────
// 받침 가운데(bx, by)·반지름 R 은 캔버스 px. 엄지(tx, ty)도 px.
// raw 는 엄지 위치를 반지름으로 나눈 값 — 길이 1 을 넘으면 1 로 자른다(손잡이는 테두리까지만 간다)
// 화면 y 는 아래가 + 라서 raw y 도 아래가 + 다
type Stick = {
bx: number; by: number; R: number;
tx: number; ty: number; down: boolean;
rx: number; ry: number;
kx: number; ky: number;
hx: number; hy: number; hdown: boolean;
};
function makeStick(bx: number, by: number, R: number): Stick {
return { bx: bx, by: by, R: R, tx: bx, ty: by, down: false, rx: 0, ry: 0, kx: 0, ky: 0, hx: bx, hy: by, hdown: false };
}
function readRaw(s: Stick): void {
if (!s.down) { s.rx = 0; s.ry = 0; return; }
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) { x /= m; y /= m; }
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x: number, y: number, dz: number): [number, number] {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) { return [0, 0]; }
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
// 화면에 보이는 손잡이(kx, ky) — raw 를 빠르게 따라간다(02 는 스프링으로 따로)
const KNOB_FOLLOW_RATE = 28;
function followKnob(s: Stick, dt: number): void {
const f = follow(KNOB_FOLLOW_RATE, dt);
s.kx += (s.rx - s.kx) * f;
s.ky += (s.ry - s.ky) * f;
}
function snapKnob(s: Stick): void { s.kx = s.rx; s.ky = s.ry; }
// ── 방향 이름 ────────────────────────────────────────────────
// 0 = 오른쪽, 화면 시계 방향(아래가 +y)으로 45° 씩
const DIR_NAMES = ['오른쪽', '오른쪽 아래', '아래', '왼쪽 아래', '왼쪽', '왼쪽 위', '위', '오른쪽 위'];
const CENTER_NAME = '가운데';
function sectorOf(x: number, y: number): number {
const k = Math.round(Math.atan2(y, x) / (Math.PI / 4));
return ((k % 8) + 8) % 8;
}
// 상태 줄 한 마디 — 세기는 5% 단위로 반올림해 글자가 매 프레임 바뀌지 않게
function dirText(x: number, y: number): string {
const m = Math.hypot(x, y);
const pct = Math.round(clamp(m, 0, 1) * 20) * 5;
if (pct === 0) { return '방향 {d} · 세기 {s}%'.replace('{d}', CENTER_NAME).replace('{s}', '0'); }
return '방향 {d} · 세기 {s}%'.replace('{d}', DIR_NAMES[sectorOf(x, y)]).replace('{s}', String(pct));
}
// ── 가상 엄지의 길 ───────────────────────────────────────────
// 열쇠 [시각 s, x, y, 닿음 1/0] 사이를 부드럽게 잇는다. 닿음은 지난 열쇠의 값
type PathKey = [number, number, number, number];
type Pose = { x: number; y: number; down: boolean };
function pathAt(keys: PathKey[], t: number): Pose {
if (t <= keys[0][0]) { return { x: keys[0][1], y: keys[0][2], down: keys[0][3] > 0 }; }
for (let k = 0; k < keys.length - 1; k += 1) {
const a = keys[k];
const b = keys[k + 1];
if (t < b[0]) {
const u = ease((t - a[0]) / (b[0] - a[0]));
return { x: lerp(a[1], b[1], u), y: lerp(a[2], b[2], u), down: a[3] > 0 };
}
}
const z = keys[keys.length - 1];
return { x: z[1], y: z[2], down: z[3] > 0 };
}
// 손떨림 — 시드로 뽑은 주파수·위상의 사인 셋을 더한 값(크기 amp 안팎). 같은 시드면 같은 떨림
function makeTremor(seed: number): (t: number, amp: number) => [number, number] {
const r = seeded(seed);
const f: number[] = [];
const p: number[] = [];
for (let k = 0; k < 6; k += 1) { f.push(5 + r() * 9); p.push(r() * TAU); }
return function (t: number, amp: number): [number, number] {
const x = Math.sin(t * f[0] + p[0]) * 0.5 + Math.sin(t * f[1] + p[1]) * 0.3 + Math.sin(t * f[2] + p[2]) * 0.2;
const y = Math.sin(t * f[3] + p[3]) * 0.5 + Math.sin(t * f[4] + p[4]) * 0.3 + Math.sin(t * f[5] + p[5]) * 0.2;
return [x * amp, y * amp];
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 스틱을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 방향키 스틱 값(스틱마다) — 화면 쪽이 눌린 키로 고친다
const keyX: number[] = [0, 0];
const keyY: number[] = [0, 0];
const keyOn: boolean[] = [false, false];
// 스틱 입력원 고르기 — 자동 시연 → 방향키 → 포인터 순
function driveSticks(list: Stick[], auto: (i: number, t: number) => Pose): void {
for (let i = 0; i < list.length; i += 1) {
const s = list[i];
if (autoOn) {
const p = auto(i, sceneT);
s.tx = p.x; s.ty = p.y; s.down = p.down;
} else if (keyOn[i]) {
s.tx = s.bx + keyX[i] * s.R; s.ty = s.by + keyY[i] * s.R; s.down = true;
} else {
s.tx = s.hx; s.ty = s.hy; s.down = s.hdown;
}
readRaw(s);
}
}
// 파스텔 팔레트 — 글자·선은 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [250, 246, 240];
const PAPER_DIM: RGB = [234, 228, 220];
const INK: RGB = [40, 36, 54];
const INK_SOFT: RGB = [110, 102, 124];
const LAVENDER: RGB = [196, 184, 240];
const MINT: RGB = [166, 220, 200];
const BUTTER: RGB = [247, 226, 156];
const CORAL: RGB = [244, 174, 158];
const SKY: RGB = [180, 212, 240];
const PINK: RGB = [244, 196, 214];
const VIOLET_DEEP: RGB = [86, 62, 160];
const MINT_DEEP: RGB = [32, 110, 86];
const CORAL_DEEP: RGB = [176, 66, 54];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 진한 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 화살표 — 끝에 작은 머리
function arrow(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < 2) { return; }
seg(x1, y1, x2, y2, width, c, a);
const ang = Math.atan2(y2 - y1, x2 - x1);
const hl = Math.min(10, len * 0.5);
seg(x2, y2, x2 - Math.cos(ang - 0.5) * hl, y2 - Math.sin(ang - 0.5) * hl, width, c, a);
seg(x2, y2, x2 - Math.cos(ang + 0.5) * hl, y2 - Math.sin(ang + 0.5) * hl, width, c, a);
}
// 받침 — 옅은 원판과 테두리
function drawBase(x: number, y: number, R: number, a: number): void {
disc(x, y, R, PAPER_DIM, 0.78 * a);
ring(x, y, R, 2, INK, 0.28 * a);
seg(x - R * 0.2, y, x + R * 0.2, y, 1, INK, 0.2 * a);
seg(x, y - R * 0.2, x, y + R * 0.2, 1, INK, 0.2 * a);
}
// 손잡이 — 받침 가운데에서 (kx, ky)·R 만큼 떨어진 자리
function drawKnob(s: Stick, c: RGB, a: number): void {
const x = s.bx + s.kx * s.R;
const y = s.by + s.ky * s.R;
const r = s.R * 0.44;
disc(x + 2, y + 4, r, INK, 0.16 * a);
disc(x, y, r, c, a);
ring(x, y, r, 2, INK, 0.45 * a);
disc(x - r * 0.3, y - r * 0.32, r * 0.32, PAPER, 0.5 * a);
}
// 가상 엄지 — 닿아 있는 동안 반투명 원
function drawThumb(x: number, y: number, on: boolean): void {
if (!on) { return; }
disc(x, y, 17, INK, 0.16);
ring(x, y, 17, 2, INK, 0.38);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 09 두 스틱으로 나는 드론 시점 장면 ─────────────────────────
// 투영 — 화면 x = 가운데 + FOCAL_LENGTH × 옆 거리 / 깊이, 화면 y = 지평선 + FOCAL_LENGTH × (고도 − 높이) / 깊이
const FOCAL_LENGTH = 250;
// 깊이가 NEAR_Z 보다 가까운(또는 뒤에 있는) 점은 그리지 않는다 — 0 으로 나누기와 뒤집힌 그림을 막는다
const NEAR_Z = 2;
const FAR_Z = 260;
// 안개 — 깊이 z 에서 안개 몫 = 1 − e^(−z × FOG_DENSITY)
const FOG_DENSITY = 0.011;
const HORIZON_Y = 112;
// 왼쪽 스틱: 앞뒤 FLY_SPEED · 좌우 STRAFE_SPEED, 오른쪽 스틱: 돌기 YAW_RATE(라디안/초) · 오르내리기 CLIMB_RATE
const FLY_SPEED = 28;
const STRAFE_SPEED = 18;
const YAW_RATE = 1.1;
const CLIMB_RATE = 9;
// 스틱 값이 바뀌어도 속도는 이 빠르기로 따라간다 — 드론의 무게
const DRONE_RESPONSE = 3.5;
const DRONE_DEADZONE = 0.1;
const ALT_MIN = 3;
const ALT_MAX = 24;
// 세상 한 칸(이 크기로 같은 배치가 되풀이된다)
const CELL = 120;
const LOOP_S = 8;
const sticks: Stick[] = [makeStick(60, 244, 34), makeStick(420, 244, 34)];
const PATH_09L: PathKey[] = [
[0, 0, 0, 0], [0.22, 0, -0.1, 1], [0.6, 0.15, -0.85, 1], [2.0, -0.25, -0.8, 1], [3.4, 0.3, -0.9, 1], [4.8, -0.2, -0.7, 1],
[6.2, 0.1, -0.85, 1], [7.5, 0, -0.5, 1], [7.8, 0, 0, 1], [8, 0, 0, 0]
];
const PATH_09R: PathKey[] = [
[0, 0, 0, 0], [0.22, 0, 0, 1], [0.7, 0.55, -0.3, 1], [1.8, 0.6, 0.2, 1], [2.8, -0.5, 0.1, 1], [4.2, -0.4, -0.4, 1],
[5.4, 0.5, 0.35, 1], [6.8, 0.2, -0.2, 1], [7.6, 0, 0, 1], [8, 0, 0, 0]
];
function autoPose(i: number, t: number): Pose {
const s = sticks[i];
const p = pathAt(i === 0 ? PATH_09L : PATH_09R, t);
return { x: s.bx + p.x * s.R, y: s.by + p.y * s.R, down: p.down };
}
type Drone = { x: number; z: number; yaw: number; alt: number; vf: number; vs: number; vyaw: number; valt: number };
const drone: Drone = { x: 0, z: 0, yaw: 0, alt: 13, vf: 0, vs: 0, vyaw: 0, valt: 0 };
// 한 칸 안의 배치 — 나무 [x, z, 높이, 색] · 건물 상자 [x, z, 폭, 깊이, 높이, 색]
const trees: number[] = [];
const blocks: number[] = [];
(function (): void {
const r = seeded(909);
for (let k = 0; k < 7; k += 1) { trees.push(r() * CELL, r() * CELL, 7 + r() * 6, Math.floor(r() * 2)); }
for (let k = 0; k < 3; k += 1) { blocks.push(r() * CELL, r() * CELL, 9 + r() * 10, 9 + r() * 10, 6 + r() * 14, Math.floor(r() * 3)); }
})();
function fogOf(z: number): number { return 1 - Math.exp(-z * FOG_DENSITY); }
// 점 하나를 화면으로 — [화면 x, 화면 y, 깊이] 또는 근평면 앞이면 null
function project(X: number, Y: number, Z: number): [number, number, number] | null {
const dx = X - drone.x;
const dz = Z - drone.z;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
const side = dx * c - dz * s;
const depth = dx * s + dz * c;
if (depth <= NEAR_Z) { return null; }
return [W / 2 + FOCAL_LENGTH * side / depth, HORIZON_Y + FOCAL_LENGTH * (drone.alt - Y) / depth, depth];
}
function depthOf(X: number, Z: number): number {
return (X - drone.x) * Math.sin(drone.yaw) + (Z - drone.z) * Math.cos(drone.yaw);
}
let outL: [number, number] = [0, 0];
let outR: [number, number] = [0, 0];
function flyStep(dt: number): void {
const f = follow(DRONE_RESPONSE, dt);
drone.vf += (-outL[1] * FLY_SPEED - drone.vf) * f;
drone.vs += (outL[0] * STRAFE_SPEED - drone.vs) * f;
drone.vyaw += (outR[0] * YAW_RATE - drone.vyaw) * f;
drone.valt += (-outR[1] * CLIMB_RATE - drone.valt) * f;
drone.yaw += drone.vyaw * dt;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
drone.x += (s * drone.vf + c * drone.vs) * dt;
drone.z += (c * drone.vf - s * drone.vs) * dt;
drone.alt = clamp(drone.alt + drone.valt * dt, ALT_MIN, ALT_MAX);
}
function reset(): void {
for (const s of sticks) { s.kx = 0; s.ky = 0; s.rx = 0; s.ry = 0; s.down = false; }
drone.x = 0; drone.z = 0; drone.yaw = 0; drone.alt = 13; drone.vf = 0; drone.vs = 0; drone.vyaw = 0; drone.valt = 0;
outL = [0, 0]; outR = [0, 0];
}
function update(dt: number): void {
driveSticks(sticks, autoPose);
followKnob(sticks[0], dt);
followKnob(sticks[1], dt);
outL = radialDeadzone(sticks[0].rx, sticks[0].ry, DRONE_DEADZONE);
outR = radialDeadzone(sticks[1].rx, sticks[1].ry, DRONE_DEADZONE);
flyStep(dt);
}
function restPose(): void {
for (const s of sticks) { s.down = false; readRaw(s); snapKnob(s); }
drone.x = 30; drone.z = 10; drone.yaw = 0.5; drone.alt = 10; drone.vf = 0; drone.vs = 0; drone.vyaw = 0; drone.valt = 0;
outL = [0, 0]; outR = [0, 0];
}
// 멈춘 화면 — 지금 스틱 값으로 SETTLE_FLY_S 초 날아간 자리에 바로(속도는 남기지 않는다)
const SETTLE_FLY_S = 0.4;
function settle(): void {
driveSticks(sticks, autoPose);
snapKnob(sticks[0]);
snapKnob(sticks[1]);
outL = radialDeadzone(sticks[0].rx, sticks[0].ry, DRONE_DEADZONE);
outR = radialDeadzone(sticks[1].rx, sticks[1].ry, DRONE_DEADZONE);
drone.vf = -outL[1] * FLY_SPEED; drone.vs = outL[0] * STRAFE_SPEED;
drone.vyaw = outR[0] * YAW_RATE; drone.valt = -outR[1] * CLIMB_RATE;
drone.yaw += drone.vyaw * SETTLE_FLY_S;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
drone.x += (s * drone.vf + c * drone.vs) * SETTLE_FLY_S;
drone.z += (c * drone.vf - s * drone.vs) * SETTLE_FLY_S;
drone.alt = clamp(drone.alt + drone.valt * SETTLE_FLY_S, ALT_MIN, ALT_MAX);
drone.vf = 0; drone.vs = 0; drone.vyaw = 0; drone.valt = 0;
}
function mapPointer(x: number, y: number): [number, number, number] { return [x < W / 2 ? 0 : 1, x, y]; }
function stickWord(o: [number, number]): [string, string] {
const pct = Math.round(clamp(Math.hypot(o[0], o[1]), 0, 1) * 20) * 5;
return [pct === 0 ? CENTER_NAME : DIR_NAMES[sectorOf(o[0], o[1])], String(pct)];
}
function statusText(): string {
const a = stickWord([sticks[0].rx, sticks[0].ry]);
const b = stickWord([sticks[1].rx, sticks[1].ry]);
return '왼쪽 {d} {s}% · 오른쪽 {e} {t}%'.replace('{d}', a[0]).replace('{s}', a[1]).replace('{e}', b[0]).replace('{t}', b[1]);
}
const SKY_TOP: RGB = [176, 204, 238];
const FOG_COLOR: RGB = [232, 230, 236];
const GROUND_NEAR: RGB = [170, 214, 186];
const GRID_STEP = 20;
const NEAR_FADE_Z = 14;
const NEAR_FADE_LEN = 22;
const TREE_COLORS: RGB[] = [[132, 196, 160], [160, 206, 150]];
const BLOCK_COLORS: RGB[] = [mix(PAPER, LAVENDER, 0.35), mix(PAPER, PINK, 0.3), mix(PAPER, BUTTER, 0.35)];
// 안개 섞기 — 깊이가 멀수록 물체 색을 안개색으로
function fogged(c: RGB, z: number): RGB { return mix(c, FOG_COLOR, fogOf(z)); }
// 바닥 격자 선 하나 — 짧은 토막으로 잘라 토막마다 깊이 안개, 근평면 앞 토막은 근평면에서 잘라 낸다
function groundLine(x1: number, z1: number, x2: number, z2: number): void {
const N = 10;
for (let k = 0; k < N; k += 1) {
let ax = lerp(x1, x2, k / N);
let az = lerp(z1, z2, k / N);
let bx = lerp(x1, x2, (k + 1) / N);
let bz = lerp(z1, z2, (k + 1) / N);
const da = depthOf(ax, az);
const db = depthOf(bx, bz);
if (da <= NEAR_Z && db <= NEAR_Z) { continue; }
if (da <= NEAR_Z || db <= NEAR_Z) {
const u = (NEAR_Z + 0.01 - da) / (db - da);
if (da <= NEAR_Z) { ax = lerp(ax, bx, u); az = lerp(az, bz, u); } else { bx = lerp(ax, bx, u); bz = lerp(az, bz, u); }
}
const pa = project(ax, 0, az);
const pb = project(bx, 0, bz);
if (!pa || !pb || pa[2] > FAR_Z) { continue; }
seg(pa[0], pa[1], pb[0], pb[1], 1.6, INK, 0.4 * (1 - fogOf((pa[2] + pb[2]) / 2)));
}
}
type Item = { z: number; kind: number; i: number; ox: number; oz: number };
const LIGHT_X = 0.5;
const LIGHT_Z = -0.8;
function polygon(pts: ([number, number, number] | null)[], c: RGB): boolean {
for (const p of pts) { if (!p) { return false; } }
ctx.beginPath();
for (let k = 0; k < pts.length; k += 1) {
const p = pts[k] as [number, number, number];
if (k === 0) { ctx.moveTo(p[0], p[1]); } else { ctx.lineTo(p[0], p[1]); }
}
ctx.closePath();
ctx.fillStyle = rgba(c, 1);
ctx.fill();
return true;
}
function drawBlock(b: number, ox: number, oz: number, z: number): void {
const cx = blocks[b] + ox;
const cz = blocks[b + 1] + oz;
const hw = blocks[b + 2] / 2;
const hd = blocks[b + 3] / 2;
const h = blocks[b + 4];
const base = BLOCK_COLORS[blocks[b + 5]];
// 옆면 넷 — 바깥쪽 법선이 드론 쪽을 향하는 면만
const faces: [number, number, number, number, number, number][] = [
[cx - hw, cz - hd, cx + hw, cz - hd, 0, -1], [cx + hw, cz - hd, cx + hw, cz + hd, 1, 0],
[cx + hw, cz + hd, cx - hw, cz + hd, 0, 1], [cx - hw, cz + hd, cx - hw, cz - hd, -1, 0]
];
for (const f of faces) {
const mx = (f[0] + f[2]) / 2;
const mz = (f[1] + f[3]) / 2;
if ((drone.x - mx) * f[4] + (drone.z - mz) * f[5] <= 0) { continue; }
const lit = 0.72 + 0.28 * clamp(f[4] * LIGHT_X + f[5] * LIGHT_Z, -1, 1);
const c = fogged([base[0] * lit, base[1] * lit, base[2] * lit], z);
polygon([project(f[0], 0, f[1]), project(f[2], 0, f[3]), project(f[2], h, f[3]), project(f[0], h, f[1])], c);
}
if (drone.alt > h) {
polygon([project(cx - hw, h, cz - hd), project(cx + hw, h, cz - hd), project(cx + hw, h, cz + hd), project(cx - hw, h, cz + hd)], fogged(mix(base, PAPER, 0.3), z));
}
}
function drawTree(t: number, ox: number, oz: number): void {
const x = trees[t] + ox;
const z = trees[t + 1] + oz;
const h = trees[t + 2];
const a = project(x, 0, z);
const b = project(x, h * 0.35, z);
const top = project(x, h, z);
if (!a || !b || !top) { return; }
const r = FOCAL_LENGTH * h * 0.28 / a[2];
seg(a[0], a[1], b[0], b[1], Math.max(1, r * 0.25), fogged([150, 120, 104], a[2]), 1);
ctx.beginPath();
ctx.moveTo(top[0], top[1]);
ctx.lineTo(b[0] + r, b[1]);
ctx.lineTo(b[0] - r, b[1]);
ctx.closePath();
ctx.fillStyle = rgba(fogged(TREE_COLORS[trees[t + 3]], a[2]), 1);
ctx.fill();
}
let drawnCount = 0;
function draw(): void {
// 하늘과 땅 — 지평선 쪽은 안개색으로 녹는다
const sky = ctx.createLinearGradient(0, 0, 0, HORIZON_Y);
sky.addColorStop(0, rgba(SKY_TOP, 1));
sky.addColorStop(1, rgba(FOG_COLOR, 1));
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, HORIZON_Y + 1);
const ground = ctx.createLinearGradient(0, HORIZON_Y, 0, H);
ground.addColorStop(0, rgba(FOG_COLOR, 1));
ground.addColorStop(Math.min(0.9, 0.12 + drone.alt / 40), rgba(fogged(GROUND_NEAR, 60), 1));
ground.addColorStop(1, rgba(GROUND_NEAR, 1));
ctx.fillStyle = ground;
ctx.fillRect(0, HORIZON_Y, W, H - HORIZON_Y);
const gx0 = Math.floor((drone.x - FAR_Z) / GRID_STEP) * GRID_STEP;
const gz0 = Math.floor((drone.z - FAR_Z) / GRID_STEP) * GRID_STEP;
for (let g = 0; g <= (FAR_Z * 2) / GRID_STEP; g += 1) {
const xx = gx0 + g * GRID_STEP;
const zz = gz0 + g * GRID_STEP;
if (Math.abs(xx - drone.x) < 160) { groundLine(xx, drone.z - 160, xx, drone.z + 160); }
if (Math.abs(zz - drone.z) < 160) { groundLine(drone.x - 160, zz, drone.x + 160, zz); }
}
// 물체 모으기 — 드론 둘레 칸들의 나무·상자, 깊이를 재서 먼 것부터
const items: Item[] = [];
const ci = Math.floor(drone.x / CELL);
const cj = Math.floor(drone.z / CELL);
for (let i = ci - 2; i <= ci + 2; i += 1) {
for (let j = cj - 2; j <= cj + 2; j += 1) {
const ox = i * CELL;
const oz = j * CELL;
for (let t = 0; t < trees.length; t += 4) {
const z = depthOf(trees[t] + ox, trees[t + 1] + oz);
if (z > NEAR_Z && z < FAR_Z) { items.push({ z: z, kind: 0, i: t, ox: ox, oz: oz }); }
}
for (let b = 0; b < blocks.length; b += 6) {
const z = depthOf(blocks[b] + ox, blocks[b + 1] + oz);
if (z > NEAR_Z + 4 && z < FAR_Z) { items.push({ z: z, kind: 1, i: b, ox: ox, oz: oz }); }
}
}
}
items.sort(function (a: Item, b: Item): number { return b.z - a.z; });
for (const it of items) {
// 가까운 물체는 깊이 NEAR_FADE_Z + NEAR_FADE_LEN 안쪽부터 옅어져 NEAR_FADE_Z 에서 사라진다 — 드론이 지나칠 때 화면을 통째로 가리지 않게
ctx.globalAlpha = clamp((it.z - NEAR_FADE_Z) / NEAR_FADE_LEN, 0, 1);
if (it.kind === 0) { drawTree(it.i, it.ox, it.oz); } else { drawBlock(it.i, it.ox, it.oz, it.z); }
}
ctx.globalAlpha = 1;
drawnCount = items.length;
for (let k = 0; k < sticks.length; k += 1) {
const s = sticks[k];
drawBase(s.bx, s.by, s.R, 0.85);
drawKnob(s, k === 0 ? MINT : LAVENDER, 0.95);
drawThumb(s.tx, s.ty, s.down);
}
tag('먼 것부터 {n}개 · 고도 {a}'.replace('{n}', String(drawnCount)).replace('{a}', drone.alt.toFixed(1)), W / 2, 282, PAPER, INK, 10, 'center');
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 스틱을 잡으면 자동 시연은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 들어온 입력은 넘침·관성 없이 결과 자리로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus(statusText());
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키를 누르고 있으면 스틱 값이 이 빠르기로 끝까지 차오른다 — 짧게 톡 치면 작은 기울기
const KEY_RAMP_RATE = 7;
const held: { [key: string]: boolean } = {};
let keyStick = 0;
function keyTarget(): [number, number] {
let x = (held.ArrowRight ? 1 : 0) - (held.ArrowLeft ? 1 : 0);
let y = (held.ArrowDown ? 1 : 0) - (held.ArrowUp ? 1 : 0);
const m = Math.hypot(x, y);
if (m > 0) { x /= m; y /= m; }
return [x, y];
}
function anyHeld(): boolean { return !!(held.ArrowRight || held.ArrowLeft || held.ArrowDown || held.ArrowUp); }
function rampKeys(dt: number): void {
const t = keyTarget();
for (let i = 0; i < keyOn.length; i += 1) {
const tx = i === keyStick ? t[0] : 0;
const ty = i === keyStick ? t[1] : 0;
if (dt <= 0) { keyX[i] = tx; keyY[i] = ty; } else {
const f = follow(KEY_RAMP_RATE, dt);
keyX[i] += (tx - keyX[i]) * f;
keyY[i] += (ty - keyY[i]) * f;
}
keyOn[i] = (i === keyStick && anyHeld()) || Math.hypot(keyX[i], keyY[i]) > 0.02;
if (!keyOn[i] && !(i === keyStick && anyHeld())) { keyX[i] = 0; keyY[i] = 0; }
}
}
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 손잡이와 대상이 목표 자리에 놓인 정지 장면 한 장
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus(statusText());
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 무대 밖에서는 화살표가 원래대로 페이지를 스크롤한다.
// Shift 를 함께 누르면 둘째 스틱(있을 때)
const ARROWS: { [key: string]: boolean } = { ArrowLeft: true, ArrowUp: true, ArrowRight: true, ArrowDown: true };
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (!ARROWS[e.key] || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
const want = e.shiftKey && sticks.length > 1 ? 1 : 0;
if (want !== keyStick) {
for (const k of Object.keys(held)) { held[k] = false; }
keyStick = want;
}
held[e.key] = true;
if (still || paused) { rampKeys(0); }
showNow();
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if (!ARROWS[e.key]) { return; }
held[e.key] = false;
if (still || paused) { rampKeys(0); showNow(); }
}, { signal: off.signal });
pad.addEventListener('blur', function (): void {
for (const k of Object.keys(held)) { held[k] = false; }
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 손가락만 잡는다(포인터 캡처: 무대 밖으로 나가도 그 손가락을 놓치지 않는다).
// 스크롤 가로채기는 무대 상자에만 건 touch-action: none 으로 끝난다 — 무대 밖 터치는 페이지 스크롤 그대로
const grabbed: { [id: string]: number } = {};
function toStage(e: PointerEvent): [number, number] {
const r = pad.getBoundingClientRect();
return [(e.clientX - r.left) / r.width * W, (e.clientY - r.top) / r.height * H];
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) { return; }
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
sticks[i].hx = g[0] === i ? g[1] : p[0];
sticks[i].hy = g[0] === i ? g[2] : p[1];
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
const i = grabbed[String(e.pointerId)];
if (i === undefined) { return; }
delete grabbed[String(e.pointerId)];
sticks[i].hdown = false;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'가상 조이스틱 무대 — 누른 뒤 방향키로 기울이기 · Shift 와 함께 누르면 오른쪽 스틱'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'두 스틱이 정해진 비행 경로로 움직여 바닥 격자와 나무·건물 상자 사이를 나는 드론 시점 장면을 먼 것부터 그리고, 멀수록 안개가 짙어진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'왼쪽 가운데 0% · 오른쪽 가운데 0%'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 두 스틱으로 나는 드론 시점 장면 — 측정 누적면적 51.987% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function fogOf(z) { return 1 - Math.exp(-z * FOG_DENSITY); }
// 점 하나를 화면으로 — [화면 x, 화면 y, 깊이] 또는 근평면 앞이면 null
function project(X, Y, Z) {
const dx = X - drone.x;
const dz = Z - drone.z;
const s = Math.sin(drone.yaw);
const c = Math.cos(drone.yaw);
const side = dx * c - dz * s;
const depth = dx * s + dz * c;
if (depth <= NEAR_Z) {
return null;
}
return [W / 2 + FOCAL_LENGTH * side / depth, HORIZON_Y + FOCAL_LENGTH * (drone.alt - Y) / depth, depth];
}
모바일 조이스틱 ui 는 엄지 좌표를 어떤 순서로 바꿔 읽을까?
무대 상자가 pointerdown 을 받으면 화면 좌표를 480×300 무대 좌표로 바꾸고, 장면의 mapPointer 가 그 자리를 맡을 스틱을 고른 뒤에야 setPointerCapture 로 그 포인터를 붙잡는다. 붙잡힌 포인터는 엄지가 무대 밖으로 미끄러져도 pointermove·pointerup 을 계속 이 상자로 보내 주므로, 손잡이가 테두리에 걸린 채 버려지지 않는다. 포인터 번호마다 따로 붙잡기 때문에 09 에서는 두 손가락이 두 스틱을 하나씩 맡는다.
pad.addEventListener('pointerdown', function (e) {
const p = toStage(e);
const g = mapPointer(p[0], p[1]);
if (g[0] < 0) {
return;
}
const s = sticks[g[0]];
takeOver();
pad.setPointerCapture(e.pointerId);
grabbed[String(e.pointerId)] = g[0];
s.hdown = true;
s.hx = g[1];
s.hy = g[2];
showNow();
}, { signal: off.signal });
붙잡은 다음에는 readRaw 가 (엄지 − 받침) ÷ R 을 길이 1 에서 자른 값을 만들고, 장면마다 아래 절의 깎기·휘기·무게 싣기를 거친다. 화면 y 가 아래로 커지므로 스틱 y 도 아래쪽이 + 다.
모바일 웹 미니게임에 붙일 때
touch-action: none 은 무대 상자에만 걸려 있어, 터치 스크린 조이스틱을 긴 글 중간에 끼워도 무대 밖을 쓸면 페이지가 그대로 스크롤된다. 화면 왼쪽 절반 어디를 눌러도 시작되는 05 의 받침에 02 의 되돌아오는 손잡이를 얹으면, 엄지가 화면을 내려다보지 않고도 스틱 자리를 찾기 쉬울 것으로 보인다. 걷는 캐릭터가 칸 단위로 움직이는 게임이면 05 의 출력을 04 의 pickSector 에 넘겨 방향이 경계에서 떨지 않게 한다.
지도·평면도 둘러보기
길 안내나 층별 안내처럼 큰 그림을 작은 화면에서 훑는 곳에는 06 이 맞는다. 평면도 크기와 RUBBER_LIMIT 60 만 내 그림에 맞게 바꾸면, 끝까지 밀어도 경계 밖이 60px 넘게 드러나지 않아 지도의 끝을 놓치지 않는다. 손을 떼면 1초 뒤 속도가 0.05배로 줄어 다음 터치 전에 화면이 대부분 멈춘다.
원격 조작 화면
카메라 각도나 기계 팔 방향을 원격으로 맞추는 관리 화면은 작은 기울기가 섬세해야 하니 03 의 곡선을 먼저 고른다. 두 축을 서로 다른 손에 맡겨야 하면 09 처럼 스틱을 둘로 나누고, 상태 줄의 「왼쪽 {방향} {세기}% · 오른쪽 {방향} {세기}%」 문장을 조작 기록에 그대로 옮겨 적을 수 있다.
데드존으로 깎고, 지수 곡선으로 휘고, 스프링과 저항으로 무게를 싣는다 — 스틱 값이 움직임이 되는 순서
조이스틱 데드존 뜻을 한 문장으로 줄이면 「받침 가운데 둘레의 이만큼은 기울어도 없던 일로 친다」 이다. 다만 그 둘레를 지우기만 하면 출력이 0.15 를 넘는 순간 0 에서 0.15 로 튀므로, radialDeadzone 은 남은 0.85 구간을 0~1 로 다시 펴서 이어 붙인다. 아래 표는 엄지 값이 화면의 움직임이 되기까지 거치는 네 단계와 그 단계를 쓰는 장면이다.
| 단계 | 하는 일 | 함수 | 쓰는 장면 |
|---|---|---|---|
| 1 읽기 | (엄지 − 받침) ÷ R, 길이 1 을 넘으면 자름 | readRaw |
01~09 |
| 2 깎기 | 데드존 안은 0, 바깥은 0~1 로 다시 폄 | radialDeadzone |
01·03·06·08·09 (04 는 세기 0.3 미만을 방향 없음으로) |
| 3 휘기 | 세기를 1.8 제곱 | responseCurve |
03 |
| 4 무게 | 잔걸음마다 속도를 먼저 고치고 그 새 속도로 위치를 옮김 | springStep·stepPan·carStep·shipStep |
02·07·08 (1/240초)·06 (1/120초) |
function readRaw(s) {
if (!s.down) {
s.rx = 0;
s.ry = 0;
return;
}
let x = (s.tx - s.bx) / s.R;
let y = (s.ty - s.by) / s.R;
const m = Math.hypot(x, y);
if (m > 1) {
x /= m;
y /= m;
}
s.rx = x;
s.ry = y;
}
// 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
function radialDeadzone(x, y, dz) {
const m = Math.hypot(x, y);
if (m <= dz || m === 0) {
return [0, 0];
}
const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
return [x * k, y * k];
}
다시 펴지 않으면 첫 구간이 사라진다
자르기만 한 데드존은 기울기 0.15 를 넘는 순간 출력이 0.15 부터 시작해, 그 아래 섬세한 구간을 쓸 수 없다. 다시 펴면 0.2 가 0.059, 0.3 이 0.176 으로 0 에서부터 부드럽게 오르고, 끝 1 은 그대로 1 이다. 01 시연 열쇠처럼 엄지를 −8° 로 0.646 만큼 기울이면 원형은 0.584·−8° 를, 네모는 0.576·0° 를 내놓아 네모 쪽만 방향이 축으로 꺾인다.
휘기는 시간을 모르고 무게는 시간을 안다
03 의 곡선은 같은 입력에 같은 출력을 돌려줄 뿐이라, 손을 떼면 그 프레임에 조준점이 선다. 02·06·07·08 은 출력을 속도나 힘에 더하므로 손을 뗀 뒤에도 몇 프레임 더 움직이고, 이때 걸음 길이를 계산에 어떻게 넣느냐에 따라 프레임률이 결과를 바꾼다. 06 이 감쇠를 Math.pow(PAN_DAMPING, h) 로 잡은 것도 그 때문이다.
먼 것부터 그리는 드론 장면
09 는 둘레 5×5 칸에 되풀이해 둔 나무 7그루와 건물 상자 3개를 깊이로 줄 세우고 내림차순으로 그려, 가까운 나무가 먼 건물을 덮게 한다. 깊이 36 안쪽부터 옅게 지워 14 에서 사라지게 해서, 드론이 바로 옆을 지나칠 때 물체 하나가 화면을 통째로 가리지 않는다.
items.sort(function (a, b) { return b.z - a.z; });
for (const it of items) {
// 가까운 물체는 깊이 NEAR_FADE_Z + NEAR_FADE_LEN 안쪽부터 옅어져 NEAR_FADE_Z 에서 사라진다 — 드론이 지나칠 때 화면을 통째로 가리지 않게
ctx.globalAlpha = clamp((it.z - NEAR_FADE_Z) / NEAR_FADE_LEN, 0, 1);
if (it.kind === 0) {
drawTree(it.i, it.ox, it.oz);
}
else {
drawBlock(it.i, it.ox, it.oz, it.z);
}
}
조이스틱이 깨지는 순간 — 경계에서 떨리는 8방향·받침 밖으로 나간 엄지·숨은 탭 뒤에 한꺼번에 밀려온 관성
22.5° 경계 위에서 떠는 엄지
04 시연은 0.42~2.3초 동안 엄지를 22.5° 경계에 두고 ±6° 로 떨게 한다. 이 구간에서 여유 각 8° 인 민트 캐릭터는 방향을 0번 바꾸고, 여유 각 0 인 산호 캐릭터는 7번 바꿔 지그재그로 걷는다. 같은 손떨림을 10초로 늘려 재면 여유 각 0° 38번·2° 30번·4° 10번·8° 0번·12° 0번이라, 떨림 폭 6° 를 넘는 여유 각부터 흔들림이 사라지는 것으로 보인다. 고칠 곳은 pickSector 의 22.5 + hyst 조건 하나다.
테두리에 걸린 손잡이
제자리 받침에서는 엄지가 받침 가운데에서 3R 나갔다가 되돌아올 때, 출력이 반대쪽으로 돌아서려면 엄지가 126.5px 를 되짚어야 한다. 1.5R 이면 63.5px, 2R 이면 84.5px 로 나간 만큼 늘어나지만, 끌려오는 받침은 나간 거리와 상관없이 42.5px 만 되짚으면 돌아선다. 시연에서는 1.0초에 엄지가 되돌기 시작해 끌려오는 판이 1.167초, 제자리 판이 1.367초에 돌아서서 0.2초가 벌어진다.
1.2초를 한 걸음에 쓴 판
받은 시간을 그대로 쓰는 naiveStep 은 지난 속도로 위치를 먼저 옮기므로, 1.2초 프레임이면 그 속도 × 1.2초가 한 번에 화면에 찍힌다. 고친 stepPan 은 상한 0.05초로 자르고 1/120초 잔걸음마다 속도부터 고치기 때문에 같은 프레임에서 24.4px 만 간다. 데모는 두 판을 산호 점선과 작은 지도에 1.7초 동안 겹쳐 보여 준다.
// 고친 판 — 상한으로 자른 뒤 잔걸음으로 나눠, 걸음마다 속도를 먼저 고치고 위치를 옮긴다
function stepPan(p, ox, oy, dt) {
let left = Math.min(dt, PAN_DT_CAP);
while (left > 1e-9) {
const h = Math.min(PAN_SUBSTEP, left);
const a = accelOf(p, ox, oy);
const keep = Math.pow(PAN_DAMPING, h);
p.vx = (p.vx + a[0] * h) * keep;
p.vy = (p.vy + a[1] * h) * keep;
p.x += p.vx * h;
p.y += p.vy * h;
left -= h;
}
}
// 깨진 판 — 받은 dt 를 그대로 한 걸음에. 지난 속도로 위치를 먼저 옮기므로 긴 프레임이면 그 속도 × dt 만큼 한 번에 튄다
function naiveStep(p, ox, oy, dt) {
const a = accelOf(p, ox, oy);
p.x += p.vx * dt;
p.y += p.vy * dt;
const keep = Math.pow(PAN_DAMPING, dt);
p.vx = (p.vx + a[0] * dt) * keep;
p.vy = (p.vy + a[1] * dt) * keep;
}
실제로 탭이 숨는 경우는 아홉 장면이 같이 쓰는 루프가 막는다. 숨으면 루프를 세우고, 다시 돌 때 lastTs 를 −1 로 돌려 숨어 있던 시간을 첫 프레임에 넘기지 않으며, 그래도 길어진 프레임은 DT_MAX 0.05초에서 자른다.
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
rampKeys(dt);
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
깨진 판과 고친 판은 06 폴더의 index.html 한 파일에 함께 들어 있다. 압축 파일을 여는 암호 97ynwj4h 를 넣어 풀고 그 파일을 열면, 시연 1.25초에 두 판이 갈라지는 순간을 일시정지 버튼으로 붙잡아 나란히 견줄 수 있다.
데드존을 넓히면 손떨림은 얼마나 사라질까 — 데드존 반지름·응답 지수·바퀴 접지력을 바꿔 본 차이
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 데드존을 넓게 | 01 DEADZONE_RADIUS 0.15 → 0.3 |
멈춤 넓이 몫 2.3% → 9%, 기울기 0.5 의 출력 0.412 → 0.286, 0.8 은 0.765 → 0.714 |
| 응답 곡선을 더 휘게 | 03 RESPONSE_EXP 1.8 → 3 |
입력 0.3 에서 초당 22.8px → 4.1px(60px 가는 데 2.63초 → 14.63초), 곧은 비례의 절반 빠르기 자리 0.42 → 0.707 |
| 바퀴를 덜 붙게 | 07 TIRE_GRIP 0.85 → 0.5 |
끝까지 꺾은 미끄러짐 각 12.1° → 25.5°, 한 바퀴 자국 187.9px → 460.3px, 자국 남긴 시간 몫 18.8% → 53.4% |
값을 고르기 전에 볼 숫자
데드존을 0.3 으로 넓히면 기울기 0.3 의 출력이 0.176 에서 0 이 되고, 01 시연 열쇠(세기 0.646)의 출력도 0.584 에서 0.495 로 떨어진다. 01 시연의 떨림 최대 0.101 은 0.15 안에 이미 들어가므로, 손떨림이 목적이라면 데드존을 넓히기보다 0.15 를 두고 03 의 곡선으로 작은 기울기를 늦추는 편이 끝 쪽 빠르기를 덜 잃는 것으로 보인다. 지수 3 은 입력 0.8 에서도 초당 193px 를 143.8px 로 낮추니, 빠른 이동이 잦은 화면이라면 1.8~3 사이에서 고른다.
TIRE_GRIP 은 남기는 몫이 아니라 깎는 몫이다. 0.85 면 0.15초마다 옆 속도의 15% 만 남고, 0.5 로 낮추면 절반이 남아 차가 더 미끄러진다. 0.85 를 「85% 가 남는다」 로 읽으면 값을 바꿀 때 방향을 거꾸로 짐작하게 되니, 소스의 w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S) 한 줄을 먼저 확인해 두면 좋다.
세 상수는 각 항목 폴더의 index.html 에서 이름으로 찾을 수 있고, 숫자 하나를 고쳐 저장한 뒤 그 파일을 브라우저로 다시 열면 위 표의 차이가 바로 보인다.
방향키로도 같은 기울기 — 스틱 방향을 읽어 주는 문장과 움직임 줄이기에서 가라앉는 관성의 접근성
손 대신 방향키
무대 상자에는 tabindex="0"·role="application" 과 「누른 뒤 방향키로 기울이기」 라는 이름이 붙어 있다. 방향키를 누르고 있으면 스틱 값이 KEY_RAMP_RATE 7 의 빠르기로 끝까지 차오르고 떼면 같은 빠르기로 0 이 되어, 짧게 톡 치면 작은 기울기가 된다. 화살표 기본 동작은 수식 키(Alt·Ctrl·Meta) 없이 그 상자 안에서 눌렸을 때만 막으므로, 무대 밖 화살표는 아홉 장면 다 페이지 스크롤로 간다. 왼쪽 위 상태 줄은 처음엔 읽지 않는 off 였다가 사람이 한 번 손대면 polite 로 바뀌어 「방향 {d} · 세기 {s}%」 를 5% 단위로 알린다.
움직임 줄이기에서 가라앉는 관성
prefers-reduced-motion: reduce 이면 루프를 세우고, 넘침·관성·입자·안개 흐름 없이 손잡이와 대상이 결과 자리에 놓인 한 장만 그린다. 0.4초와 1.2초에 찍은 두 장의 화소 차가 아홉 장면 다 0 이었고, 그 상태에서도 방향키를 한 번 누르면 넘침 없이 결과 자리로 옮긴 한 장을 다시 그려 화면이 바뀐다. 오른쪽 위 일시정지 버튼은 누르면 aria-pressed 가 true 로 바뀐다. 포인터 사건의 순서는 MDN Pointer events에, 붙잡기는 MDN setPointerCapture에, 스크롤을 넘겨받는 범위는 MDN touch-action에 정리되어 있다.
가상 조이스틱 FAQ — 마우스로 쓰기·두 손가락 동시 터치·게임 말고 쓰는 곳
모바일 조이스틱 ui 를 데스크톱에서 마우스로도 쓸 수 있나요
포인터 사건으로 받기 때문에 마우스 끌기도 손가락과 같은 길로 들어온다. 무대에서 누른 채 끌면 setPointerCapture 가 그 포인터를 붙잡아 커서가 무대 밖으로 나가도 손잡이가 따라오고, 키보드만 쓴다면 무대에 초점을 준 뒤 방향키로 기울이면 된다.
두 손가락으로 동시에 누르면 어떻게 되나요
포인터마다 번호(pointerId)로 따로 붙잡으므로, 09 에서는 한 손가락이 이동 스틱을, 다른 손가락이 방향·고도 스틱을 동시에 맡는다. 키보드로는 09 에서 Shift 와 함께 방향키를 누르면 오른쪽 스틱이 기운다.
게임 말고 어디에 쓰나요
06 처럼 큰 평면도를 작은 화면에서 둘러보거나, 03 처럼 원격 카메라 각도를 잘게 맞추는 관리 화면에 쓸 수 있다. 장면마다 스틱 값은 update 안에서 대상에 넘겨지므로, 그 대상만 내 화면의 지도나 카메라로 바꾸면 데드존과 응답 곡선은 그대로 따라온다.









