GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

가상 조이스틱 9종 — 조이스틱 원리부터 드론 시점까지

가상 조이스틱은 화면에 그린 원판을 엄지로 밀어 방향과 세기를 한 번에 넣는 터치 조작부다. 스틱이 가운데서 살짝만 기울어도 캐릭터가 움직이면 손떨림까지 화면에 찍힌다.

기울기를 그대로 속도로 쓰면 손떨림까지 움직인다 — 가상 조이스틱은 엄지 위치를 고쳐 읽는 데서 시작한다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 장면은 엄지 좌표를 받침 반지름 R 로 나눈 −1~1 값 하나에서 출발한다. 01~04 는 그 값을 읽고 보여 주는 방식을, 05 는 받침을 어디에 둘지를, 06~09 는 고친 값을 지도·차·우주선·드론의 움직임으로 옮기는 방법을 다룬다. 손가락 대신 시드로 정한 가상 엄지가 매번 같은 경로를 그리므로, 숫자를 바꾼 판과 원래 판을 나란히 놓고 견줄 수 있다.

엄지가 떨기만 해도 화면이 꿈틀대는 이유

01 시연의 0.3~0.7초 구간에서 엄지는 가운데를 벗어나지 않고 떨기만 하는데, 그 사이 입력 세기가 최대 0.101 까지 오른다. 데드존 0.15 를 두지 않았다면 이 떨림이 고스란히 출력이 되어 캐릭터가 제자리에서 움찔거렸을 것으로 보인다. 데드존을 거친 원형·네모 두 출력은 그 97프레임 내내 0 이었다.

1.2초짜리 한 프레임이 평면도를 581.6px 밀어낸다

06 은 시연 1.25초에 1.2초 길이의 프레임 하나를 일부러 끼워 넣는다. 받은 시간을 한 걸음에 다 쓴 판은 평면도가 581.6px 튀면서 경계를 최대 440.8px 넘었다가 되돌아오고, 0.05초에서 자른 뒤 1/120초씩 나눠 걸은 판은 24.4px 만 옮겨 간다. 엄지 값을 고쳐 읽는 일만큼 프레임 시간을 고쳐 읽는 일도 이 편의 한 축이다.

이웃한 편과 갈리는 지점

css starfield 편이 별의 깊이에 따라 이동량을 나눠 원근을 만들었다면, 09 는 같은 나눗셈(초점 거리 ÷ 깊이)을 두 스틱이 정한 자리와 방향에서 프레임마다 다시 계산한다. 2048 게임 편의 쓸기 판정은 손을 뗀 뒤 길이·시간·각으로 네 방향 가운데 하나를 한 번 고르지만, 가상 조이스틱은 엄지가 닿아 있는 동안 방향과 세기를 쉬지 않고 이어서 읽는다. 눌림 값이 제자리로 돌아오는 방식은 버튼 눌림 효과 편이 버튼 쪽에서 따로 모았고, 계산으로 그린 캔버스 장면의 기준선은 물방울·블랙홀 캔버스 효과 편이다.

가상 조이스틱 9종 — 원형 데드존 바깥을 다시 펴는 출력·놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이·작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점·경계가 끈끈한 8방향 타일 걷기·엄지를 따라 끌려오는 떠 있는 받침·관성으로 둘러보는 평면도와 고무줄 가장자리·옆으로 미끄러진 만큼 남는 바퀴 자국·추력과 회전 관성으로 나는 우주선과 배기 입자·두 스틱으로 나는 드론 시점 장면

01·03 은 같은 엄지 값을 깎고 휘는 읽기, 02 는 놓인 손잡이가 가운데로 돌아오는 모양이고 04·05 는 방향 칸과 받침 자리라는 판정의 경계를 다룬다. 06~08 은 스틱 값을 가속으로 넣어 평면도·차·우주선에 무게를 싣고, 09 는 스틱 두 개로 나는 드론 시점이다. 아홉 장면은 캔버스 2D 하나씩에 그리며 라이브러리·WebGL·이미지 파일·외부 웹폰트를 쓰지 않고, 난수는 시드를 고정한 seeded 하나뿐이다.

480×300 무대를 2초 동안 24장 찍어 잰 움직임

장면 누적면적 움직인 프레임 강도 평균
01 원형·네모 데드존 출력 지도 28.8% 23/23 41.7
02 넘쳤다 돌아오는 손잡이 20.3% 23/23 69.5
03 응답 곡선 조준점 15.3% 23/23 59.7
04 끈끈한 8방향 걷기 17.5% 23/23 70.0
05 끌려오는 받침 30.2% 23/23 64.4
06 관성 평면도 65.6% 23/23 75.2
07 미끄러지는 차와 자국 13.8% 23/23 80.7
08 추력 우주선 21.8% 23/23 65.8
09 두 스틱 드론 시점 52.0% 23/23 37.6

평면도 전체가 미끄러지는 06 이 65.6% 로 가장 넓고, 차 한 대와 자국만 바뀌는 07 은 13.8% 에 그치지만 강도 평균은 80.7 로 가장 높다. 바닥 격자와 안개가 화면 대부분을 채우는 09 는 52.0% 를 덮으면서 강도가 37.6 로 가장 낮아, 넓은 면이 조금씩 바뀌는 장면으로 보인다.

01원형 데드존 바깥을 다시 펴는 출력

모바일 게임 설정 화면의 데드존 미리보기처럼, 왼쪽 원판은 엄지 거리가 DEADZONE_RADIUS 0.15 안이면 출력 0, 바깥이면 (거리 − 0.15) ÷ 0.85 로 다시 펴서 기울기 0.5 를 0.412, 0.8 을 0.765 로 내보내고 방향은 엄지 그대로 둔다. 오른쪽 네모 데드존은 가로와 세로를 따로 깎는 탓에 세기 0.3 에서 출력 방향이 엄지보다 최대 30° 축 쪽으로 붙고, 세기 1 에서도 8.62° 가 남는다. 두 원판을 72×72 칸마다 계산해 출력 0 은 산호, 방향이 어긋난 칸은 민트, 지금 세기와 같은 칸은 버터 띠로 칠하는데, 그 띠가 원형에서는 고리이고 네모에서는 둥근 네모다.

원형 데드존 바깥을 다시 펴는 출력
새 창에서 열기

01 · 원형 데드존 바깥을 다시 펴는 출력 — 측정 누적면적 28.839% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DEADZONE_RADIUSSQUARE_DEADZONEHEAT_CELLS
    function axisCut(v, dz) {
        const a = Math.abs(v);
        return a <= dz ? 0 : Math.sign(v) * (a - dz) / (1 - dz);
    }
    function squareDeadzone(x, y, dz) {
        let ox = axisCut(x, dz);
        let oy = axisCut(y, dz);
        const m = Math.hypot(ox, oy);
        if (m > 1) {
            ox /= m;
            oy /= m;
        }
        return [ox, oy];
    }

02놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이

웹 게임 첫 화면의 조작 안내에 어울리는 장면으로, 놓인 손잡이에 가운데로 당기는 힘 KNOB_STIFFNESS 210 과 속도를 깎는 KNOB_DAMPING 12 를 1/240초마다 더한다. 감쇠비가 0.414 라 끝에서 놓으면 0.238초에 반대쪽으로 23.4% 넘치고 0.475초에 처음 쪽으로 5.5% 되넘치며, ±2% 띠 안에 끝까지 머물기 시작하는 시각은 0.571초다. 가운데에서 멀수록 손잡이를 진행 방향으로 납작하게 누르고 그림자 높이를 16px 에서 끝 5.6px 까지 줄여, 기울인 손잡이가 바닥 쪽으로 눌린 듯한 높이 차를 만든다.

놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이
새 창에서 열기

02 · 놓이면 넘쳤다 돌아오는 손잡이와 그림자 높이 — 측정 누적면적 20.33% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KNOB_STIFFNESSKNOB_DAMPINGTILT_MAX
    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초면 닿는다. 구석 그래프의 버터 점이 지금 입력과 출력의 자리를 따라가고, 옅은 산호 조준점이 같은 스틱을 곧은 비례로 받아 두 빠르기의 차이를 나란히 보인다.

작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점
새 창에서 열기

03 · 작은 기울기를 섬세하게 바꾸는 응답 곡선 조준점 — 측정 누적면적 15.269% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RESPONSE_EXPAIM_SPEEDAIM_DEADZONE
    // 응답 곡선 — 방향은 두고 세기만 휜다
    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 아래면 방향이 없는 것으로 보고 제자리에 선다.

경계가 끈끈한 8방향 타일 걷기
새 창에서 열기

04 · 경계가 끈끈한 8방향 타일 걷기 — 측정 누적면적 17.483% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HYSTERESIS_DEGWALK_DEADZONETILE_STEP_S
    // 칸 판정 — 여유 각 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 다.

엄지를 따라 끌려오는 떠 있는 받침
새 창에서 열기

05 · 엄지를 따라 끌려오는 떠 있는 받침 — 측정 누적면적 30.213% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BASE_RADIUSBASE_FOLLOW_RATEleashBase
    // 끌려오는 받침 — 넘은 거리만큼 받침을 엄지 쪽으로 옮긴다
    function leashBase(s) {
        const dx = s.tx - s.bx;
        const dy = s.ty - s.by;
        const d = Math.hypot(dx, dy);
        if (d > s.R) {
            const k = 1 - s.R / d;
            s.bx += dx * k;
            s.by += dy * k;
        }
    }

06관성으로 둘러보는 평면도와 고무줄 가장자리

매장 배치도나 전시장 안내 지도를 둘러보는 화면처럼, 스틱 출력에 1600 을 곱해 속도에 더하고 1초에 속도가 0.05배가 되도록 1/120초마다 깎아 손을 떼면 미끄러지다 멈춘다. 깎는 몫을 걸음 길이의 거듭제곱으로 잡아서 초당 500px 에서 놓으면 3초 동안 30fps 164.8px·60fps 164.8px·144fps 165.2px 로 프레임률과 거의 상관없이 같은 거리를 간다. 960×620 평면도의 경계를 넘은 만큼은 60 × (1 − 1 ÷ (넘은 거리 ÷ 60 + 1)) 만 화면에 보여 60px 넘으면 30px, 600px 넘어도 54.5px 에서 버티고, 오른쪽 아래 작은 지도에는 지금 보이는 범위가 보라 사각형으로 뜬다.

관성으로 둘러보는 평면도와 고무줄 가장자리
새 창에서 열기

06 · 관성으로 둘러보는 평면도와 고무줄 가장자리 — 측정 누적면적 65.642% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PAN_DAMPINGRUBBER_LIMITPAN_DT_CAP
    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 가 남는다.

옆으로 미끄러진 만큼 남는 바퀴 자국
새 창에서 열기

07 · 옆으로 미끄러진 만큼 남는 바퀴 자국 — 측정 누적면적 13.785% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WHEELBASETIRE_GRIPSKID_MIN
    function carStep(c, throttle, h) {
        let fx = Math.cos(c.psi);
        let fy = Math.sin(c.psi);
        let u = c.vx * fx + c.vy * fy;
        u += (throttle * ENGINE_ACCEL - u * ROLL_DRAG) * h;
        // 앞뒤 바퀴 거리와 조향각으로 구하는 회전 반경 — 그 원을 따라 머리를 돌린다
        if (Math.abs(c.steer) > 1e-4) {
            c.psi += u * Math.tan(c.steer) / WHEELBASE * h;
        }
        const w0 = c.vx * -fy + c.vy * fx;
        // 머리가 돌았으니 같은 속도를 새 앞·옆 방향으로 다시 나눈다 — 돈 만큼이 옆 속도로 넘어간다
        const vx = fx * u + -fy * w0;
        const vy = fy * u + fx * w0;
        fx = Math.cos(c.psi);
        fy = Math.sin(c.psi);
        const u2 = vx * fx + vy * fy;
        let w = vx * -fy + vy * fx;
        w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S);
        c.slip = Math.abs(w);
        c.u = u2;
        c.vx = fx * u2 + -fy * w;
        c.vy = fy * u2 + fx * w;
        c.x += c.vx * h;
        c.y += c.vy * h;
    }

08추력과 회전 관성으로 나는 우주선과 배기 입자

웹 게임 랜딩 페이지 히어로처럼, 스틱 방향과 우주선 머리의 각도 차를 −180~180° 로 감아 머리 170° 에서 목표 −170° 를 받으면 −340° 를 도는 대신 20° 만 돈다. 그 차에 40 을 곱한 회전 힘과 각속도에 9 를 곱한 제동을 함께 걸어 90° 돌리기는 3.5° 넘쳤다가 0.908초에 2° 안으로 자리 잡고, 추력 300 과 저항 1.1 이 맞서 끝 속도는 272.7 에서 더 늘지 않는다. 꼬리 불꽃은 1초에 세기 × 90 개씩 우주선 속도를 물려받아 뒤로 뿌려지고, 늙을수록 커지면서 버터에서 산호·라벤더로 옅어진다.

추력과 회전 관성으로 나는 우주선과 배기 입자
새 창에서 열기

08 · 추력과 회전 관성으로 나는 우주선과 배기 입자 — 측정 누적면적 21.767% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TURN_STIFFNESSSHIP_DRAGEXHAUST_SPEED
    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 안쪽 점은 그리지 않고, 나무와 건물 상자는 먼 것부터 그려 가까운 것이 위에 덮이게 한다.

두 스틱으로 나는 드론 시점 장면
새 창에서 열기

09 · 두 스틱으로 나는 드론 시점 장면 — 측정 누적면적 51.987% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCAL_LENGTHFOG_DENSITYNEAR_Z
    function fogOf(z) { return 1 - Math.exp(-z * FOG_DENSITY); }
    // 점 하나를 화면으로 — [화면 x, 화면 y, 깊이] 또는 근평면 앞이면 null
    function project(X, Y, Z) {
        const dx = X - drone.x;
        const dz = Z - drone.z;
        const s = Math.sin(drone.yaw);
        const c = Math.cos(drone.yaw);
        const side = dx * c - dz * s;
        const depth = dx * s + dz * c;
        if (depth <= NEAR_Z) {
            return null;
        }
        return [W / 2 + FOCAL_LENGTH * side / depth, HORIZON_Y + FOCAL_LENGTH * (drone.alt - Y) / depth, depth];
    }

모바일 조이스틱 ui 는 엄지 좌표를 어떤 순서로 바꿔 읽을까?

무대 상자가 pointerdown 을 받으면 화면 좌표를 480×300 무대 좌표로 바꾸고, 장면의 mapPointer 가 그 자리를 맡을 스틱을 고른 뒤에야 setPointerCapture 로 그 포인터를 붙잡는다. 붙잡힌 포인터는 엄지가 무대 밖으로 미끄러져도 pointermove·pointerup 을 계속 이 상자로 보내 주므로, 손잡이가 테두리에 걸린 채 버려지지 않는다. 포인터 번호마다 따로 붙잡기 때문에 09 에서는 두 손가락이 두 스틱을 하나씩 맡는다.

    pad.addEventListener('pointerdown', function (e) {
        const p = toStage(e);
        const g = mapPointer(p[0], p[1]);
        if (g[0] < 0) {
            return;
        }
        const s = sticks[g[0]];
        takeOver();
        pad.setPointerCapture(e.pointerId);
        grabbed[String(e.pointerId)] = g[0];
        s.hdown = true;
        s.hx = g[1];
        s.hy = g[2];
        showNow();
    }, { signal: off.signal });

붙잡은 다음에는 readRaw 가 (엄지 − 받침) ÷ R 을 길이 1 에서 자른 값을 만들고, 장면마다 아래 절의 깎기·휘기·무게 싣기를 거친다. 화면 y 가 아래로 커지므로 스틱 y 도 아래쪽이 + 다.

모바일 웹 미니게임에 붙일 때

touch-action: none 은 무대 상자에만 걸려 있어, 터치 스크린 조이스틱을 긴 글 중간에 끼워도 무대 밖을 쓸면 페이지가 그대로 스크롤된다. 화면 왼쪽 절반 어디를 눌러도 시작되는 05 의 받침에 02 의 되돌아오는 손잡이를 얹으면, 엄지가 화면을 내려다보지 않고도 스틱 자리를 찾기 쉬울 것으로 보인다. 걷는 캐릭터가 칸 단위로 움직이는 게임이면 05 의 출력을 04 의 pickSector 에 넘겨 방향이 경계에서 떨지 않게 한다.

지도·평면도 둘러보기

길 안내나 층별 안내처럼 큰 그림을 작은 화면에서 훑는 곳에는 06 이 맞는다. 평면도 크기와 RUBBER_LIMIT 60 만 내 그림에 맞게 바꾸면, 끝까지 밀어도 경계 밖이 60px 넘게 드러나지 않아 지도의 끝을 놓치지 않는다. 손을 떼면 1초 뒤 속도가 0.05배로 줄어 다음 터치 전에 화면이 대부분 멈춘다.

원격 조작 화면

카메라 각도나 기계 팔 방향을 원격으로 맞추는 관리 화면은 작은 기울기가 섬세해야 하니 03 의 곡선을 먼저 고른다. 두 축을 서로 다른 손에 맡겨야 하면 09 처럼 스틱을 둘로 나누고, 상태 줄의 「왼쪽 {방향} {세기}% · 오른쪽 {방향} {세기}%」 문장을 조작 기록에 그대로 옮겨 적을 수 있다.

데드존으로 깎고, 지수 곡선으로 휘고, 스프링과 저항으로 무게를 싣는다 — 스틱 값이 움직임이 되는 순서

조이스틱 데드존 뜻을 한 문장으로 줄이면 「받침 가운데 둘레의 이만큼은 기울어도 없던 일로 친다」 이다. 다만 그 둘레를 지우기만 하면 출력이 0.15 를 넘는 순간 0 에서 0.15 로 튀므로, radialDeadzone 은 남은 0.85 구간을 0~1 로 다시 펴서 이어 붙인다. 아래 표는 엄지 값이 화면의 움직임이 되기까지 거치는 네 단계와 그 단계를 쓰는 장면이다.

단계 하는 일 함수 쓰는 장면
1 읽기 (엄지 − 받침) ÷ R, 길이 1 을 넘으면 자름 readRaw 01~09
2 깎기 데드존 안은 0, 바깥은 0~1 로 다시 폄 radialDeadzone 01·03·06·08·09 (04 는 세기 0.3 미만을 방향 없음으로)
3 휘기 세기를 1.8 제곱 responseCurve 03
4 무게 잔걸음마다 속도를 먼저 고치고 그 새 속도로 위치를 옮김 springStep·stepPan·carStep·shipStep 02·07·08 (1/240초)·06 (1/120초)
    function readRaw(s) {
        if (!s.down) {
            s.rx = 0;
            s.ry = 0;
            return;
        }
        let x = (s.tx - s.bx) / s.R;
        let y = (s.ty - s.by) / s.R;
        const m = Math.hypot(x, y);
        if (m > 1) {
            x /= m;
            y /= m;
        }
        s.rx = x;
        s.ry = y;
    }
    // 원형 데드존 — 길이 m 이 dz 안이면 0, 밖이면 (m − dz)/(1 − dz) 로 0~1 을 다시 펴고 방향은 그대로
    function radialDeadzone(x, y, dz) {
        const m = Math.hypot(x, y);
        if (m <= dz || m === 0) {
            return [0, 0];
        }
        const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
        return [x * k, y * k];
    }

다시 펴지 않으면 첫 구간이 사라진다

자르기만 한 데드존은 기울기 0.15 를 넘는 순간 출력이 0.15 부터 시작해, 그 아래 섬세한 구간을 쓸 수 없다. 다시 펴면 0.2 가 0.059, 0.3 이 0.176 으로 0 에서부터 부드럽게 오르고, 끝 1 은 그대로 1 이다. 01 시연 열쇠처럼 엄지를 −8° 로 0.646 만큼 기울이면 원형은 0.584·−8° 를, 네모는 0.576·0° 를 내놓아 네모 쪽만 방향이 축으로 꺾인다.

휘기는 시간을 모르고 무게는 시간을 안다

03 의 곡선은 같은 입력에 같은 출력을 돌려줄 뿐이라, 손을 떼면 그 프레임에 조준점이 선다. 02·06·07·08 은 출력을 속도나 힘에 더하므로 손을 뗀 뒤에도 몇 프레임 더 움직이고, 이때 걸음 길이를 계산에 어떻게 넣느냐에 따라 프레임률이 결과를 바꾼다. 06 이 감쇠를 Math.pow(PAN_DAMPING, h) 로 잡은 것도 그 때문이다.

먼 것부터 그리는 드론 장면

09 는 둘레 5×5 칸에 되풀이해 둔 나무 7그루와 건물 상자 3개를 깊이로 줄 세우고 내림차순으로 그려, 가까운 나무가 먼 건물을 덮게 한다. 깊이 36 안쪽부터 옅게 지워 14 에서 사라지게 해서, 드론이 바로 옆을 지나칠 때 물체 하나가 화면을 통째로 가리지 않는다.

        items.sort(function (a, b) { return b.z - a.z; });
        for (const it of items) {
            // 가까운 물체는 깊이 NEAR_FADE_Z + NEAR_FADE_LEN 안쪽부터 옅어져 NEAR_FADE_Z 에서 사라진다 — 드론이 지나칠 때 화면을 통째로 가리지 않게
            ctx.globalAlpha = clamp((it.z - NEAR_FADE_Z) / NEAR_FADE_LEN, 0, 1);
            if (it.kind === 0) {
                drawTree(it.i, it.ox, it.oz);
            }
            else {
                drawBlock(it.i, it.ox, it.oz, it.z);
            }
        }

조이스틱이 깨지는 순간 — 경계에서 떨리는 8방향·받침 밖으로 나간 엄지·숨은 탭 뒤에 한꺼번에 밀려온 관성

22.5° 경계 위에서 떠는 엄지

04 시연은 0.42~2.3초 동안 엄지를 22.5° 경계에 두고 ±6° 로 떨게 한다. 이 구간에서 여유 각 8° 인 민트 캐릭터는 방향을 0번 바꾸고, 여유 각 0 인 산호 캐릭터는 7번 바꿔 지그재그로 걷는다. 같은 손떨림을 10초로 늘려 재면 여유 각 0° 38번·2° 30번·4° 10번·8° 0번·12° 0번이라, 떨림 폭 6° 를 넘는 여유 각부터 흔들림이 사라지는 것으로 보인다. 고칠 곳은 pickSector 의 22.5 + hyst 조건 하나다.

테두리에 걸린 손잡이

제자리 받침에서는 엄지가 받침 가운데에서 3R 나갔다가 되돌아올 때, 출력이 반대쪽으로 돌아서려면 엄지가 126.5px 를 되짚어야 한다. 1.5R 이면 63.5px, 2R 이면 84.5px 로 나간 만큼 늘어나지만, 끌려오는 받침은 나간 거리와 상관없이 42.5px 만 되짚으면 돌아선다. 시연에서는 1.0초에 엄지가 되돌기 시작해 끌려오는 판이 1.167초, 제자리 판이 1.367초에 돌아서서 0.2초가 벌어진다.

1.2초를 한 걸음에 쓴 판

받은 시간을 그대로 쓰는 naiveStep 은 지난 속도로 위치를 먼저 옮기므로, 1.2초 프레임이면 그 속도 × 1.2초가 한 번에 화면에 찍힌다. 고친 stepPan 은 상한 0.05초로 자르고 1/120초 잔걸음마다 속도부터 고치기 때문에 같은 프레임에서 24.4px 만 간다. 데모는 두 판을 산호 점선과 작은 지도에 1.7초 동안 겹쳐 보여 준다.

    // 고친 판 — 상한으로 자른 뒤 잔걸음으로 나눠, 걸음마다 속도를 먼저 고치고 위치를 옮긴다
    function stepPan(p, ox, oy, dt) {
        let left = Math.min(dt, PAN_DT_CAP);
        while (left > 1e-9) {
            const h = Math.min(PAN_SUBSTEP, left);
            const a = accelOf(p, ox, oy);
            const keep = Math.pow(PAN_DAMPING, h);
            p.vx = (p.vx + a[0] * h) * keep;
            p.vy = (p.vy + a[1] * h) * keep;
            p.x += p.vx * h;
            p.y += p.vy * h;
            left -= h;
        }
    }
    // 깨진 판 — 받은 dt 를 그대로 한 걸음에. 지난 속도로 위치를 먼저 옮기므로 긴 프레임이면 그 속도 × dt 만큼 한 번에 튄다
    function naiveStep(p, ox, oy, dt) {
        const a = accelOf(p, ox, oy);
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        const keep = Math.pow(PAN_DAMPING, dt);
        p.vx = (p.vx + a[0] * dt) * keep;
        p.vy = (p.vy + a[1] * dt) * keep;
    }

실제로 탭이 숨는 경우는 아홉 장면이 같이 쓰는 루프가 막는다. 숨으면 루프를 세우고, 다시 돌 때 lastTs 를 −1 로 돌려 숨어 있던 시간을 첫 프레임에 넘기지 않으며, 그래도 길어진 프레임은 DT_MAX 0.05초에서 자른다.

    function frame(ts) {
        if (still || paused) {
            rafId = 0;
            return;
        }
        if (lastTs < 0) {
            lastTs = ts;
        }
        const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
        lastTs = ts;
        if (dt > 0) {
            rampKeys(dt);
            advance(dt);
        }
        draw();
        pushStatus(statusText());
        rafId = window.requestAnimationFrame(frame);
    }

깨진 판과 고친 판은 06 폴더의 index.html 한 파일에 함께 들어 있다. 압축 파일을 여는 암호 97ynwj4h 를 넣어 풀고 그 파일을 열면, 시연 1.25초에 두 판이 갈라지는 순간을 일시정지 버튼으로 붙잡아 나란히 견줄 수 있다.

데드존을 넓히면 손떨림은 얼마나 사라질까 — 데드존 반지름·응답 지수·바퀴 접지력을 바꿔 본 차이

이름 바꾼 값 느낌
데드존을 넓게 01 DEADZONE_RADIUS 0.15 → 0.3 멈춤 넓이 몫 2.3% → 9%, 기울기 0.5 의 출력 0.412 → 0.286, 0.8 은 0.765 → 0.714
응답 곡선을 더 휘게 03 RESPONSE_EXP 1.8 → 3 입력 0.3 에서 초당 22.8px → 4.1px(60px 가는 데 2.63초 → 14.63초), 곧은 비례의 절반 빠르기 자리 0.42 → 0.707
바퀴를 덜 붙게 07 TIRE_GRIP 0.85 → 0.5 끝까지 꺾은 미끄러짐 각 12.1° → 25.5°, 한 바퀴 자국 187.9px → 460.3px, 자국 남긴 시간 몫 18.8% → 53.4%

값을 고르기 전에 볼 숫자

데드존을 0.3 으로 넓히면 기울기 0.3 의 출력이 0.176 에서 0 이 되고, 01 시연 열쇠(세기 0.646)의 출력도 0.584 에서 0.495 로 떨어진다. 01 시연의 떨림 최대 0.101 은 0.15 안에 이미 들어가므로, 손떨림이 목적이라면 데드존을 넓히기보다 0.15 를 두고 03 의 곡선으로 작은 기울기를 늦추는 편이 끝 쪽 빠르기를 덜 잃는 것으로 보인다. 지수 3 은 입력 0.8 에서도 초당 193px 를 143.8px 로 낮추니, 빠른 이동이 잦은 화면이라면 1.8~3 사이에서 고른다.

TIRE_GRIP 은 남기는 몫이 아니라 깎는 몫이다. 0.85 면 0.15초마다 옆 속도의 15% 만 남고, 0.5 로 낮추면 절반이 남아 차가 더 미끄러진다. 0.85 를 「85% 가 남는다」 로 읽으면 값을 바꿀 때 방향을 거꾸로 짐작하게 되니, 소스의 w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S) 한 줄을 먼저 확인해 두면 좋다.

세 상수는 각 항목 폴더의 index.html 에서 이름으로 찾을 수 있고, 숫자 하나를 고쳐 저장한 뒤 그 파일을 브라우저로 다시 열면 위 표의 차이가 바로 보인다.

방향키로도 같은 기울기 — 스틱 방향을 읽어 주는 문장과 움직임 줄이기에서 가라앉는 관성의 접근성

손 대신 방향키

무대 상자에는 tabindex="0"·role="application" 과 「누른 뒤 방향키로 기울이기」 라는 이름이 붙어 있다. 방향키를 누르고 있으면 스틱 값이 KEY_RAMP_RATE 7 의 빠르기로 끝까지 차오르고 떼면 같은 빠르기로 0 이 되어, 짧게 톡 치면 작은 기울기가 된다. 화살표 기본 동작은 수식 키(Alt·Ctrl·Meta) 없이 그 상자 안에서 눌렸을 때만 막으므로, 무대 밖 화살표는 아홉 장면 다 페이지 스크롤로 간다. 왼쪽 위 상태 줄은 처음엔 읽지 않는 off 였다가 사람이 한 번 손대면 polite 로 바뀌어 「방향 {d} · 세기 {s}%」 를 5% 단위로 알린다.

움직임 줄이기에서 가라앉는 관성

prefers-reduced-motion: reduce 이면 루프를 세우고, 넘침·관성·입자·안개 흐름 없이 손잡이와 대상이 결과 자리에 놓인 한 장만 그린다. 0.4초와 1.2초에 찍은 두 장의 화소 차가 아홉 장면 다 0 이었고, 그 상태에서도 방향키를 한 번 누르면 넘침 없이 결과 자리로 옮긴 한 장을 다시 그려 화면이 바뀐다. 오른쪽 위 일시정지 버튼은 누르면 aria-pressed 가 true 로 바뀐다. 포인터 사건의 순서는 MDN Pointer events에, 붙잡기는 MDN setPointerCapture에, 스크롤을 넘겨받는 범위는 MDN touch-action에 정리되어 있다.

가상 조이스틱 FAQ — 마우스로 쓰기·두 손가락 동시 터치·게임 말고 쓰는 곳

모바일 조이스틱 ui 를 데스크톱에서 마우스로도 쓸 수 있나요

포인터 사건으로 받기 때문에 마우스 끌기도 손가락과 같은 길로 들어온다. 무대에서 누른 채 끌면 setPointerCapture 가 그 포인터를 붙잡아 커서가 무대 밖으로 나가도 손잡이가 따라오고, 키보드만 쓴다면 무대에 초점을 준 뒤 방향키로 기울이면 된다.

두 손가락으로 동시에 누르면 어떻게 되나요

포인터마다 번호(pointerId)로 따로 붙잡으므로, 09 에서는 한 손가락이 이동 스틱을, 다른 손가락이 방향·고도 스틱을 동시에 맡는다. 키보드로는 09 에서 Shift 와 함께 방향키를 누르면 오른쪽 스틱이 기운다.

게임 말고 어디에 쓰나요

06 처럼 큰 평면도를 작은 화면에서 둘러보거나, 03 처럼 원격 카메라 각도를 잘게 맞추는 관리 화면에 쓸 수 있다. 장면마다 스틱 값은 update 안에서 대상에 넘겨지므로, 그 대상만 내 화면의 지도나 카메라로 바꾸면 데드존과 응답 곡선은 그대로 따라온다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.