javascript ジョイスティック 9種 — デッドゾーンから慣性まで
javascript ジョイスティックは、画面に描いた円盤を親指で押して向きと強さを一度に入れるタッチ操作の部品です。スティックが中心からわずかに傾いただけでキャラクターが動くと、手のふるえまで画面に出てしまいます。
傾きをそのまま速さにすると手のふるえまで動きます — javascript ジョイスティックは親指の位置を読み直すところから始まります
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
自動再生 · タイルを押すとその項目へ · すべて1つの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 星空のワープの記事が星の奥行きに応じて移動量を分けて遠近を作ったのに対し、09 は同じ割り算(焦点距離÷奥行き)を二本のスティックが決める位置と向きからフレームごとに計算し直します。2048 ゲームの記事のスワイプ判定は、指を離したあとに長さ・時間・角度で四方向のどれかを一度だけ選びますが、ジョイスティックは親指が触れているあいだ向きと強さを途切れなく読み続けます。押し込んだ値が元の位置へ戻る仕組みはボタンを押す効果の記事がボタン側で別にまとめ、計算で描くキャンバス場面の基準は水滴・ブラックホールのキャンバス効果の記事です。
javascript ジョイスティック 9種 — 円形デッドゾーンの外側から伸ばし直す出力・離れると行き過ぎて戻るつまみと影の高さ・小さな傾きを細かく扱う応答曲線の照準・境目が粘る8方向のタイル歩き・親指に引っぱられて動く浮かぶ土台・慣性で見回す平面図とゴムのような端・横すべりした分だけ残るタイヤの跡・推力と回転の慣性で飛ぶ宇宙船と噴射の粒・二本のスティックで飛ぶドローン視点の場面
01・03 は同じ親指の値を削って曲げる読み方、02 は離れたつまみが中心へ戻る形で、04・05 は向きの区切りと土台の位置という判定の境目を扱います。06〜08 はスティックの値を加速として入れて平面図・車・宇宙船に重みをのせ、09 は二本のスティックで飛ぶドローン視点です。九つの場面はキャンバス 2D を一つずつ使って描き、ライブラリ・WebGL・画像ファイル・外部 Web フォントは使わず、乱数はシードを固定した 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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離れると行き過ぎて戻るつまみと影の高さ
Webゲームのスタート画面の操作ガイドに合う場面で、離れたつまみに中心へ引く力 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 応答曲線 — 向きはそのままに強さだけを曲げる
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 区切りの判定 — 余白の角度 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 引っぱられる土台 — 越えた距離の分だけ土台を親指の方へ動かす
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)) しか表示せず、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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推力と回転の慣性で飛ぶ宇宙船と噴射の粒
Webゲームのランディングページのヒーローのように、スティックの向きと宇宙船の機首の角度の差を −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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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];
}
アナログ ジョイスティック 仕組みを画面で組み立てると、親指の座標はどの順で動きに変わるのでしょうか?
舞台の箱が 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 ひとつにまとめて入っています。zip を開くパスワード 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 にまとめられています。
javascript ジョイスティックのよくある質問 — マウスでの操作・二本指の同時タッチ・ゲーム以外の使い道
バーチャルパッド操作をパソコンのマウスでもできますか
ポインターのイベントで受けているので、マウスのドラッグも指と同じ経路で入ります。舞台で押したまま引くと setPointerCapture がそのポインターをつかみ、カーソルが舞台の外へ出てもつまみがついてきます。キーボードだけなら、舞台にフォーカスを当てて矢印キーで傾けます。
二本の指で同時に押すとどうなりますか
ポインターごとに番号(pointerId)で別々につかむので、09 では一本の指が移動のスティックを、もう一本が向きと高度のスティックを同時に受けもちます。キーボードでは 09 で Shift と一緒に矢印キーを押すと右のスティックが傾きます。
ゲーム以外ではどこに使えますか
06 のように大きな平面図を小さな画面で見回したり、03 のように遠隔カメラの角度を細かく合わせる管理画面に使えます。場面ごとにスティックの値は update の中で対象へ渡されるので、その対象だけを自分の画面の地図やカメラに差し替えれば、デッドゾーンと応答曲線はそのままついてきます。









