07옆으로 미끄러진 만큼 남는 바퀴 자국
스틱 가로 성분을 앞바퀴 조향각으로 바꿔 앞뒤 바퀴 사이 거리와 각도로 회전 반경을 구하고, 차가 진행 방향과 다르게 옆으로 미끄러지는 속도를 접지력 비율만큼 깎아, 깎고도 남은 옆 속도가 문턱을 넘는 동안 바닥에 바퀴 자국을 그린다.
쓰임주차 연습 시뮬레이터 화면
WHEELBASETIRE_GRIPSKID_MIN

<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 포함 · 가상 조이스틱 9종 — 조이스틱 원리부터 드론 시점까지
이 데모가 속한 글
가상 조이스틱 9종 — 조이스틱 원리부터 드론 시점까지가상 조이스틱은 화면에 그린 원판을 엄지로 밀어 방향과 세기를 한 번에 넣는 터치 조작부다. 스틱이 가운데서 살짝만 기울어도 캐릭터가 움직이면 손떨림까지 화면에 찍힌다. 01번은 원형 데드존 바깥을 0부터 다시 펴고 이 글의 데모 전부 보기


