GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

javascript ジョイスティック 9種 — デッドゾーンから慣性まで

javascript ジョイスティックは、画面に描いた円盤を親指で押して向きと強さを一度に入れるタッチ操作の部品です。スティックが中心からわずかに傾いただけでキャラクターが動くと、手のふるえまで画面に出てしまいます。

傾きをそのまま速さにすると手のふるえまで動きます — javascript ジョイスティックは親指の位置を読み直すところから始まります

自動再生 · タイルを押すとその項目へ · すべて1つの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 星空のワープの記事が星の奥行きに応じて移動量を分けて遠近を作ったのに対し、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 はコーラル、向きがずれたマスはミント、いまの強さと同じマスはバター色の帯で塗ると、その帯は円形では輪、四角では角丸の四角になります。

円形デッドゾーンの外側から伸ばし直す出力
新しいタブで開く

01 · 円形デッドゾーンの外側から伸ばし直す出力 — 計測 累積面積 28.839% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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離れると行き過ぎて戻るつまみと影の高さ

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 まで縮めて、傾けたつまみが床の側へ押されたような高さの差を作ります。

離れると行き過ぎて戻るつまみと影の高さ
新しいタブで開く

02 · 離れると行き過ぎて戻るつまみと影の高さ — 計測 累積面積 20.33% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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)) しか表示せず、600px 越えても 54.5px で踏ん張り、右下の小さな地図にはいま見える範囲が紫の四角で出ます。

慣性で見回す平面図とゴムのような端
新しいタブで開く

06 · 慣性で見回す平面図とゴムのような端 — 計測 累積面積 65.642% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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推力と回転の慣性で飛ぶ宇宙船と噴射の粒

Webゲームのランディングページのヒーローのように、スティックの向きと宇宙船の機首の角度の差を −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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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];
    }

アナログ ジョイスティック 仕組みを画面で組み立てると、親指の座標はどの順で動きに変わるのでしょうか?

舞台の箱が 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 の中で対象へ渡されるので、その対象だけを自分の画面の地図やカメラに差し替えれば、デッドゾーンと応答曲線はそのままついてきます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。