09두 스틱으로 나는 드론 시점 장면
왼쪽 스틱은 앞뒤·좌우 이동, 오른쪽 스틱은 방향 회전과 고도를 맡고, 바닥 격자·나무·건물 상자를 초점 거리÷깊이로 화면에 투영해 먼 것부터 그리며 깊이에 따라 안개를 짙게 얹는다. 두 스틱은 정해진 비행 경로를 따라 자동으로 움직인다.
쓰임드론 촬영 서비스 랜딩 히어로
FOCAL_LENGTHFOG_DENSITYNEAR_Z

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


