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

マウス 追従 js で動く生き物 9種 — 蛇・鯉・トカゲ・タコを計算で

マウス 追従 js で作る生き物は、頭に目標を一つ渡すだけで、残りの体が距離や角度の計算でついてくる動きです。関節を一つもキーフレームで描かないのに、生き物はどうやって目標を追って泳ぎ、歩くのでしょうか。

目標を一つ渡すだけで体全体がついてくる生き物 — 節・角度・足・触手を別々に計算した場面

自動再生 · タイルを押すとその項目へ · すべて1つのzip

何も触れないあいだ、目標は盤の真ん中を通る 8の字を描きます。横に一度揺れるあいだに縦へ二度揺れる道なので、生き物は左右の輪では大きく曲がり、中央ではまっすぐ横切ります。ポインターが盤に乗ると目標は少しずつそちらへ寄り、ポインターが離れると 8の字の道へ戻ります。

点や文字がポインターへ寄っていくマウス追従のエフェクトでは、一つひとつがポインターとの距離を自分で縮めるだけでした。ここで目標が引くのは頭だけで、残りの体は決まった間隔・曲がりの限界・骨の長さといった条件に従って引かれます。たくさんの鳥が互いを見て動く群れのシミュレーションが集団の規則なら、08 は一匹が自分の気分を切り替える規則です。

ポートフォリオのファーストビューと 404 ページ

ポートフォリオのファーストビューに一匹だけ住まわせるなら、01 の鎖の蛇がいちばん計算の単純な選択です。毎フレーム 26 個の節を置き直すだけで、訪問者がポインターを動かすと目標がそちらへ寄り、頭がそれを追いかけます。404 ページを歩き回るマスコットには 03 のトカゲが合い、地面についた足が一歩ずつ移る様子が、トップへ戻るリンクの横を歩く小さな案内役に見えます。

ショップ画面の隅にいる案内キャラなら 08 です。商品の説明を読むあいだポインターを動かさなければひとりでうろつき、ポインターが動けば寄ってきて、強くはじくと逃げ出して 1.1 秒後に落ち着きます。

海のイベントと旅館・庭園のランディング

水族館や海の体験イベントのページは 04 のタコか 06 のクラゲで始めると、テーマが一枚の絵で伝わります。秋のイベントのバナーには 07 のクモが似合い、起伏のある地面を交互に歩く姿がそのまま季節の飾りになります。旅館・庭園紹介のランディングでは 09 の池が盤の半分以上を動かし続けるので、背景動画の代わりになると考えられます。05 の芋虫は進捗バーの上を這うローディングキャラに、02 の鯉はカフェ・和食ブランドのランディング背景に向いています。

マウス 追従 js で動く生き物 9種 — 節の鎖の蛇・角度をしばった鯉・二節の足のトカゲ・伸びる触手・うねる芋虫・縮むクラゲ・交互に歩くクモ・気分のあるペット・鯉の池

どの場面も 480×300 の盤で 2 秒に 24 枚を撮り、隣り合う画像どうしの差 23 個から下の表を作りました。変化した面積がいちばん広いのは水面全体が動く 09 の池で 51.4%、いちばん狭いのは床を這う体が一つだけの 05 の芋虫で 9.6% です。9 場面とも、23 回の比較のすべてで画面が変わりました。

場面ごとに変化した面積と強さ

場面 変化した面積 動いたフレーム 強さの平均
01 鎖の蛇 14.5% 23/23 82.8
02 角度をしばった鯉 17.8% 23/23 84.1
03 トカゲ 12.7% 23/23 79.1
04 タコの触手 24.9% 23/23 89.7
05 芋虫 9.6% 23/23 95.0
06 二匹のクラゲ 18.0% 23/23 74.0
07 クモ 19.9% 23/23 101.5
08 ペット 14.5% 23/23 102.7
09 鯉の池 51.4% 23/23 51.0

01〜08 は開けた盤に生き物が一、二匹いるだけなので、面積の数字が小さいのは自然なことです。この八つで大事なのは、間隔・曲がりの限界・足どりの規則がどれだけ正確に守られるかで、その数字は各項目の説明に書きました。

01節と節の距離だけを守ってついてくる鎖の蛇

頭は残りの距離の 5 倍の速さ(上限 240px/秒)で目標に近づき、下の followChain が後ろの 25 個の節を、前の節から今の自分の位置へ向かう線の上 12px に置き直すので、体は 300px の長さに保たれます。01 は maxBend に Math.PI を渡して曲がりの限界をかけず、輪郭は節ごとの半径(頭 12.94px・いちばん太いところ 13.11px・尾 2.5px)だけ左右へ押し出した点をつないだ一つの閉じた形です。一周 1,205px の 8の字を回るあいだ、頭と尾の距離は平均 205.23px で、隣の節の曲がりは最大 0.29 ラジアンでした。

節と節の距離だけを守ってついてくる鎖の蛇
新しいタブで開く

01 · 節と節の距離だけを守ってついてくる鎖の蛇 — 計測 累積面積 14.523% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SEGMENT_GAPBODY_WIDTHHEAD_SPEED
    function followChain(xs, ys, gap, maxBend, backAngle) {
        const n = xs.length;
        let prevAng = backAngle;
        for (let i = 1; i < n; i += 1) {
            let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
            if (maxBend < Math.PI && !isNaN(prevAng)) {
                const d = wrapAngle(ang - prevAng);
                if (d > maxBend) {
                    ang = prevAng + maxBend;
                }
                else if (d < -maxBend) {
                    ang = prevAng - maxBend;
                }
            }
            xs[i] = xs[i - 1] + Math.cos(ang) * gap;
            ys[i] = ys[i - 1] + Math.sin(ang) * gap;
            prevAng = ang;
        }
    }

02曲がる角度をしばって折れずに泳ぐ鯉

steerKoi は頭の向きを 1 秒に最大 3.2 ラジアンまで目標へ回し、速さを 170px/秒(目標の 90px 内では遅く)へ寄せて 0.24 ラジアンのくねりを足したあと、MAX_BEND 0.35(およそ 20°)を渡して followChain を呼ぶので、隣どうしの節はそれ以上曲がりません。胸びれは 2〜4 番の節の曲がりの合計 × 2.2 を 0.55 に足し引きして 0.15〜1.45 に収めるため曲がりの外側が大きく開き、尾びれは最後の節の向きを追うばねです。6.5 秒の一周では 40% のフレームでどれかの節が限界に届き、尾びれは尾の節より 133ms 遅れてついてきました。

曲がる角度をしばって折れずに泳ぐ鯉
新しいタブで開く

02 · 曲がる角度をしばって折れずに泳ぐ鯉 — 計測 累積面積 17.762% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_BENDFIN_GAINTAIL_STIFF
    function steerKoi(k, dt, tx, ty, maxBend) {
        const dx = tx - k.xs[0];
        const dy = ty - k.ys[0];
        const dist = Math.hypot(dx, dy);
        const turn = wrapAngle(Math.atan2(dy, dx) - k.heading);
        k.heading = wrapAngle(k.heading + clamp(turn, -TURN_RATE * dt, TURN_RATE * dt));
        const want = SWIM_SPEED * clamp(dist / SLOW_RADIUS, 0.3, 1);
        k.speed = lerp(k.speed, want, follow(3, dt));
        k.phase += k.speed * dt * WIGGLE_FREQ;
        const move = k.heading + Math.sin(k.phase) * WIGGLE_AMP;
        k.xs[0] += Math.cos(move) * k.speed * dt;
        k.ys[0] += Math.sin(move) * k.speed * dt;
        followChain(k.xs, k.ys, k.gap, maxBend, k.heading + Math.PI);
        // 前の体の曲がり — 2・3・4番の節の曲がった角の合計。符号が曲がった向き
        const curve = bendAt(k.xs, k.ys, 2) + bendAt(k.xs, k.ys, 3) + bendAt(k.xs, k.ys, 4);
        k.curve = lerp(k.curve, curve, follow(10, dt));
        k.finL = lerp(k.finL, clamp(FIN_BASE + k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
        k.finR = lerp(k.finR, clamp(FIN_BASE - k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
        const n = KOI_JOINTS;
        const tailTarget = Math.atan2(k.ys[n - 1] - k.ys[n - 2], k.xs[n - 1] - k.xs[n - 2]);
        const acc = TAIL_STIFF * wrapAngle(tailTarget - k.tailAng) - TAIL_DAMP * k.tailVel;
        k.tailVel += acc * dt;
        k.tailAng = wrapAngle(k.tailAng + k.tailVel * dt);
    }

03足が離れすぎたときだけ一歩踏み出すトカゲ

足ごとに背骨の節から前へ 10px・横へ 30px の置き場所があり、地面についた足がそこから 38px より離れ、もう一方の対角の組が足を上げていないときだけ、0.16 秒・高さ 7px の弧でその場所より 19px 先へ移ります。下の solveKnee は太もも 22px・すね 20px と腰から足までの距離からひざを求め、前足のひじは後ろへ、後ろ足のひざは前へ曲げます。18 秒のあいだに 1 秒 5.67 歩・平均歩幅 54.28px で歩き、腰から足までが骨の合計 42px を超えた 7% の瞬間には guard が距離を切って足をまっすぐ伸ばしました。

足が離れすぎたときだけ一歩踏み出すトカゲ
新しいタブで開く

03 · 足が離れすぎたときだけ一歩踏み出すトカゲ — 計測 累積面積 12.678% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_TRIGGERTHIGH_LENSTEP_LIFT
    function solveKnee(hx, hy, fx, fy, a, b, bend, guard, out) {
        const raw = Math.hypot(fx - hx, fy - hy);
        const d = guard ? clamp(raw, Math.abs(a - b) + 0.001, a + b - 0.001) : raw;
        const cosA = (a * a + d * d - b * b) / (2 * a * d);
        const ang = Math.atan2(fy - hy, fx - hx) + bend * Math.acos(cosA);
        const kx = hx + Math.cos(ang) * a;
        const ky = hy + Math.sin(ang) * a;
        const shin = Math.atan2(fy - ky, fx - kx);
        out[0] = kx;
        out[1] = ky;
        out[2] = kx + Math.cos(shin) * b;
        out[3] = ky + Math.sin(shin) * b;
        return cosA;
    }

04先を目標に届かせて伸びるタコの触手

7 本の触手(節 12・間隔 10px・長さ 110px)は毎フレーム下の繰り返しを 4 回まわし、先を目標に置いて根元へ向かって間隔を直し、根元を元の位置に戻して先へ向かって直します。それぞれの先は目標のまわりに扇形に並んだ自分の点をねらってなめらかなノイズで最大 30px 揺れ、触手の長さより遠い目標は繰り返しの前にその方向へまっすぐ伸ばして済ませます。8 秒の一周では 7 本のうち平均 4.69 本の先が目標から 1px 以内にありました。

先を目標に届かせて伸びるタコの触手
新しいタブで開く

04 · 先を目標に届かせて伸びるタコの触手 — 計測 累積面積 24.913% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REACH_ITERTENTACLE_GAPSWAY_AMP
        for (let it = 0; it < iterations; it += 1) {
            xs[n - 1] = tx;
            ys[n - 1] = ty;
            for (let i = n - 2; i >= 0; i -= 1) {
                const d = Math.hypot(xs[i] - xs[i + 1], ys[i] - ys[i + 1]) || 1;
                xs[i] = xs[i + 1] + (xs[i] - xs[i + 1]) / d * gap;
                ys[i] = ys[i + 1] + (ys[i] - ys[i + 1]) / d * gap;
            }
            xs[0] = rx;
            ys[0] = ry;
            for (let i = 1; i < n; i += 1) {
                const d = Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]) || 1;
                xs[i] = xs[i - 1] + (xs[i] - xs[i - 1]) / d * gap;
                ys[i] = ys[i - 1] + (ys[i] - ys[i - 1]) / d * gap;
            }
        }

05体を縮めては伸ばして這う芋虫

14 個の節は頭が残した跡の上に並ぶので、節ごとに計算するのは跡にそった位置と速さだけで、targetGap は 13px の間隔を、尾から頭へ流れる正弦の波(1 秒に 11 ラジアン、周期 0.57 秒)で最大半分まで縮めます。crawl はばねで各組を目標の間隔へ引き、前へ進む節の速さには e^(−1.2·dt)、後ろへすべる節には e^(−28·dt) を掛けます。目標を遠くに固定した 10 秒では、この差だけで 81.26px/秒進み、前後の摩擦を 1.2 にそろえると −2.84px/秒で、ほとんどその場にとどまりました。

体を縮めては伸ばして這う芋虫
新しいタブで開く

05 · 体を縮めては伸ばして這う芋虫 — 計測 累積面積 9.641% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_DEPTHFRICTION_FWDFRICTION_BACK
    function targetGap(i) {
        const fromTail = CAT_JOINTS - 2 - i;
        return REST_GAP * (1 - WAVE_DEPTH * crawlDrive * Math.max(0, Math.sin(wavePhase - fromTail * WAVE_K)));
    }
    function crawl(dt) {
        wavePhase += WAVE_SPEED * dt;
        for (let i = 0; i < CAT_JOINTS; i += 1) {
            catF[i] = 0;
        }
        for (let i = 0; i < CAT_JOINTS - 1; i += 1) {
            const pull = SPRING_K * ((catS[i] - catS[i + 1]) - targetGap(i)) + SEG_DAMP * (catU[i] - catU[i + 1]);
            catF[i] -= pull;
            catF[i + 1] += pull;
        }
        for (let i = 0; i < CAT_JOINTS; i += 1) {
            catU[i] += catF[i] * dt;
            catU[i] *= Math.exp(-(catU[i] >= 0 ? FRICTION_FWD : frictionBack) * dt);
            catS[i] += catU[i] * dt;
        }
    }

06かさを縮めるたびに押し出されるクラゲ

1.2 秒の周期のうち 360ms でかさを縮めて 840ms かけて開き、stepJelly は縮みが増えた分だけ 160px/秒をかさの向きへ足すので、縮むときに速くなり、開くあいだは水の抵抗 e^(−1.6·dt) で遅くなります。かさの縁から下がる 5 本の触手(10 節)は直前の動きの 92% を残し、少し沈みながら 1 歩ごとに 3 回 11px の間隔へ戻され、小さいクラゲは同じ 8の字を 3.4 秒遅れて泳ぎます。大きいクラゲの速さは 2 周期のあいだ 28.19〜123.99px/秒、平均 67.05px/秒でした。

かさを縮めるたびに押し出されるクラゲ
新しいタブで開く

06 · かさを縮めるたびに押し出されるクラゲ — 計測 累積面積 17.967% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PULSE_IMPULSEWATER_DRAGLINK_PASSES
    function stepJelly(j, dt, gx, gy) {
        const want = Math.atan2(gy - j.y, gx - j.x);
        j.heading = wrapAngle(j.heading + clamp(wrapAngle(want - j.heading), -JELLY_TURN * dt, JELLY_TURN * dt));
        j.phase += dt / PULSE_PERIOD;
        const before = j.squeeze;
        j.squeeze = squeezeAt(j.phase);
        const push = Math.max(0, j.squeeze - before) * PULSE_IMPULSE;
        j.vx += Math.cos(j.heading) * push;
        j.vy += Math.sin(j.heading) * push;
        const drag = Math.exp(-WATER_DRAG * dt);
        j.vx *= drag;
        j.vy *= drag;
        j.x += j.vx * dt;
        j.y += j.vy * dt;
        // 盤の端 — JELLY_MARGIN(px) の内側に入ると、はみ出した距離 × 4 だけ内向きに速度を戻して盤の外へ出ないようにする
        const ox = j.x < JELLY_MARGIN ? JELLY_MARGIN - j.x : (j.x > W - JELLY_MARGIN ? W - JELLY_MARGIN - j.x : 0);
        const oy = j.y < JELLY_MARGIN ? JELLY_MARGIN - j.y : (j.y > H - JELLY_MARGIN ? H - JELLY_MARGIN - j.y : 0);
        j.vx += ox * 4 * dt;
        j.vy += oy * 4 * dt;
        for (let k = 0; k < TENTACLE_COUNT; k += 1) {
            const r = rimOf(j, k);
            stepTentacle(j.xs[k], j.ys[k], j.pxs[k], j.pys[k], r[0], r[1], dt, LINK_PASSES);
        }
    }

07四本ずつ二組で交互に歩くクモ

八本の足は隣どうしが別の組になるように二組に分かれ、順番が来た組のどれかの足が 30px より遅れると、その組の四本が 0.14 秒・高さ 14px で一緒に移り、体が一歩のあいだに進む距離の 1.6 倍だけ置き場所より前に着地します。体の高さは地面についた足の下の地面の平均 + 22px へ寄り、ひざは bend 1 で解いてから、そのひざの y が腰と足の y の中間より大きければ bend −1 で解き直して上向きに保ちます。9 秒で組の交代は 49 回、同じ組が続いたことはなく、地面についた足は最少でも 4 本、体の高さは 18.56〜25.39px のあいだを動きました。

四本ずつ二組で交互に歩くクモ
新しいタブで開く

07 · 四本ずつ二組で交互に歩くクモ — 計測 累積面積 19.912% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GAIT_SETSSTEP_DISTBODY_CLEAR
            if (worst > STEP_DIST) {
                for (let k = 0; k < spiderLegs.length; k += 1) {
                    const g = spiderLegs[k];
                    if (g.set !== nextSet) {
                        continue;
                    }
                    const h = legHome(g);
                    g.sx0 = g.fx;
                    g.sy0 = g.fy;
                    // 体が一歩のあいだに進む距離だけ前に置く
                    g.ex = h[0] + bvx * STEP_TIME * 1.6;
                    g.ey = h[1] + bvy * STEP_TIME * 1.6;
                    g.t = 0;
                }
                movingSet = nextSet;
                nextSet = (nextSet + 1) % GAIT_SETS;
                setSwaps += 1;
            }

08休むときはうろつき、ついてきて驚くと逃げるペット

目標が 25px/秒以下のまま 0.6 秒たつとペットは 45px/秒でうろつきながらなめらかなノイズで向きを変え、目標が動けば最大 170px/秒でついていき、156px(46 + 110)より近づくとめざす速さを下げて、46px でめざす速さを 0 にします。160px 以内で目標が 420px/秒より速いと、1.1 秒のあいだ反対へ 260px/秒で逃げます。デモの一周では 4.55 秒についていく状態から驚く状態へ(目標の速さ 443.66・距離 93.71)、5.63 秒にまたついていく状態へ、8.17 秒にうろうろへ切り替わりました。

休むときはうろつき、ついてきて驚くと逃げるペット
新しいタブで開く

08 · 休むときはうろつき、ついてきて驚くと逃げるペット — 計測 累積面積 14.476% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STARTLE_SPEEDARRIVE_RADIUSWANDER_TURN
        else {
            pet.idle = targetSpeed > MOVE_SPEED ? 0 : pet.idle + dt;
            pet.state = pet.idle > IDLE_DELAY ? WANDER : FOLLOW;
            if (pet.state === FOLLOW) {
                const sp = MAX_SPEED * clamp((dist - STOP_DIST) / ARRIVE_RADIUS, 0, 1);
                wantX = dx / dist * sp;
                wantY = dy / dist * sp;
            }
            else {
                pet.wanderAng += smoothNoise(pet.clock * 0.7) * WANDER_TURN * dt;
                // 盤の端に近ければ中央のほうへ向きを引き寄せる
                const edge = Math.min(pet.x, W - pet.x, pet.y, H - pet.y);
                if (edge < 70) {
                    const home = Math.atan2(H / 2 - pet.y, W / 2 - pet.x);
                    pet.wanderAng += wrapAngle(home - pet.wanderAng) * follow(3, dt);
                }
                wantX = Math.cos(pet.wanderAng) * WANDER_SPEED;
                wantY = Math.sin(pet.wanderAng) * WANDER_SPEED;
            }
        }

09鯉が通ったあとに波紋が広がる池の場面

水面は 1 マス 2px の 240×150 の高さの格子で、waveStep は 1 秒に 60 回、マスごとに「四つの隣の高さの合計の半分から一歩前の高さを引いた値」に 0.985 を掛けるので、波は 1 秒ごとに 0.40 倍に弱まります。02 と同じ steerKoi で泳ぐ三匹の鯉が尾のまわりのマスを揺らし、池の底は水面の傾き × 1.6 マスずらした位置から読むので小石がゆらぎます。真ん中の 1 マスに高さ 100 を与えた波は、15 歩で半径 28.64px、30 歩で 50.99px、60 歩で 94.53px まで広がりました。

鯉が通ったあとに波紋が広がる池の場面
新しいタブで開く

09 · 鯉が通ったあとに波紋が広がる池の場面 — 計測 累積面積 51.417% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WAVE_DAMPWAKE_GAINREFRACT
    function waveStep() {
        const now = waveNow;
        const prev = wavePrev;
        for (let y = 1; y < GRID_H - 1; y += 1) {
            const row = y * GRID_W;
            for (let x = 1; x < GRID_W - 1; x += 1) {
                const i = row + x;
                prev[i] = ((now[i - 1] + now[i + 1] + now[i - GRID_W] + now[i + GRID_W]) * 0.5 - prev[i]) * WAVE_DAMP;
            }
        }
        waveNow = prev;
        wavePrev = now;
    }

プロシージャル アニメーション は、なぜキーフレームなしで生きているように見えるのか?

頭が通った道を体がなぞる

ポインターに貼りついた画像はポインターの位置を写すだけなので、ポインターと同じ瞬間に向きを変え、同じ瞬間に動きをやめます。この場面で行き先を教わるのは頭だけで、後ろの節はそれぞれ一つ前の節が行った先を読むため、体は頭が少し前に通った道をなぞり、目標が急に向きを変えても尾は前の曲線をたどってからついてきます。魚 泳ぎ方 アニメーション の点で見ると、02 の鯉が進む向きに足しているのは 0.24 ラジアンのくねり一つだけで、体を伝わる波は後ろの節が前の節の跡をたどることから生まれています。

ひと呼吸遅れるひれと触手

キーフレームで作る 泳ぐ 魚 アニメーション なら、尾のポーズを数枚くり返す形になると考えられます。02 の尾びれは尾の節の向きを追うばねで、誤差がいちばん小さくなる遅れを測ると 133ms でした。06 の触手は直前の動きの 92% を残すので、かさより遅れて引かれてきます。同じ「今の位置と一つ前の位置」で進める方法は、やわらかい物体の物理シミュレーションで布やゼリーを動かすときにも使われています。

体は距離の制約で、ひざは余弦の関係で、足どりはしきい距離で — 生き物一匹を立たせる計算

距離だけを守る鎖

followChain が節ごとに確かめるのは、この節がいま前の節から見てどの向きにあるかだけで、その線の上にちょうど間隔の距離で節を置き直します。速度を積み重ねないので、01 の一周を通して間隔の誤差は 3.2e-14px と、計算の丸めの大きさに収まります。限界をかけるときは、その向きが前の区間の向きと maxBend より大きくずれた分を切り落とすだけです。

余弦の関係で求めるひざ

太もも a・すね b・腰から足までの距離 d が三角形を作るので、腰の角は cos A = (a² + d² − b²) / (2·a·d) を満たします。ひざは、腰から足への向きを bend × acos(cos A) だけ回した方向へ a 進んだ点で、bend の符号が曲がる側を決めます。a = 22・b = 20 で d = 30 なら cos A = 0.75 となり、ひざは (16.4, 14.66) に来ます。

先から引き、根元から引き戻す

reachChain の 1 回は、先を目標へ動かして根元へ向かって間隔を直し、根元を元の位置へ戻して先へ向かって直す往復です。根元 (0, 0) から下に垂れた触手で (70, 40) をねらうと、先の誤差は 1 回で 6.043px、2 回で 2.698px、4 回で 0.769px、8 回で 0.086px、16 回で 0.001px になり、間隔の誤差は毎回 0 でした。04 は毎フレーム前のフレームの形から続けて解くので、4 回で足りていると考えられます。

前と後ろで違う摩擦がうねりを歩みに変える

芋虫の節のあいだのばねは同じ大きさの力で押し合い引き合うので、それだけでは体の重心を動かせません。釣り合いを崩すのは床だけで、後ろへすべる節は 1 秒あたり 28、前へ進む節は 1.2 の割合で速さを失うため、縮みの波が後ろへ押す力は床に吸われ、前へ伸びる力だけが残ります。

目標が体の内側に入り込んで折れる胴・足が届かない距離で跳ねるひざ・タブから戻ったあと伸びる節 — 生き物が壊れやすい場面

目標が体の内側に入り込んだとき

02 の鯉の鎖(14 節・間隔 13px)の頭を、自分の 6 番の節の位置へ 200px/秒で 1 秒間まっすぐ押し込みました。曲がりの限界を外すと隣の節が 3.12 ラジアン(およそ 179°)まで折り返し、三つ以上離れた節の組のうち二つの半径の合計より近い組が最大 8 組でき、いちばん近い二つの節は 3.97px まで寄りました。MAX_BEND 0.35 を置くと重なる組は 0、いちばん近い節どうしは 37.42px で、体は頭と尾が 102.32px 離れた弧を描いて回ります。01 は 8の字の道では曲がり最大 0.29・重なり 0 ですが、頭を向きの制限なしに目標へ動かすので、ポインターが体の後ろ側に置かれるとこの試験と同じ動きになります。

足が届かない距離にあるとき

Math.acos は −1〜1 の外の値には NaN を返します。太もも 22px・すね 20px の足に d = 45 を渡すと cos A = 1.07 になり、ひざは (NaN, NaN)、そこから求めるすねの向きも描く足の位置も NaN になります。guard があれば d を 41.999 に切って cos A = 1 となり、ひざ (22, 0.14)・足 (42, 0.02) のまっすぐ伸びた足になります。03 はデモ中に腰から足までが最大 77.24px まで開き、7% の瞬間に 42px を超えるので、この一行が実際に働いています。

隠れたタブから戻った最初のフレーム

06 の大きいクラゲを同じ状態にそろえ、一歩で進める時間だけを変えて一度進めました。2.4 秒隠れていたタブが戻ったときのように 2.4 秒をそのまま渡すと、触手の節の間隔は最大 6.3 倍・平均 2.44 倍に伸び、真ん中の触手は休む長さ 99px から 241.12px になりました。DT_MAX の 0.05 秒で切ると最大 1.06 倍・平均 1.02 倍、長さ 100.77px です。stepTentacle で dt が入るのは沈む項 SINK × dt² だけで、2.4 秒なら 30 × 5.76 = 172.8px、0.05 秒なら 0.075px になり、間隔を直す回数は dt と関係なく 3 回のままです。

    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) {
            advance(dt);
        }
        draw();
        rafId = window.requestAnimationFrame(frame);
    }

9 場面とも、これを二重に防いでいます。上の frame は dt を DT_MAX の 0.05 秒で切り、visibilitychange のリスナーがタブが隠れているあいだループを止めて、戻ったら lastTs を −1 にして時計を合わせ直します。わざと壊して確かめたいときは、ダウンロードしたファイルを開いて 02 の MAX_BEND、03 が guard に渡している true、DT_MAX の値を書き換えてみてください。zip のパスワードは hdkq4gtb です。

節の間隔を広く、曲がりの限界を大きく、足どりのしきいを遠く — 数字一つで変わる生き物の性格

名前 変えた値 感じ
節の間隔を広く 01 SEGMENT_GAP 12 → 20 体が 300px から 500px に伸びて 8の字一周の 25% から 42% を占め、頭と尾の最も近い距離が 126.77px から 32.12px に縮む
曲がりの限界を大きく 02 MAX_BEND 0.35 → 0.7 いちばん小さい回る半径が 37.33px から 18.96px になり、限界に届くフレームが 40% から 0% に減る
足どりのしきいを遠く 03 STEP_TRIGGER 38 → 60 1 秒の歩数が 5.67 から 3.72 に減り、平均歩幅が 54.28px から 79.12px に広がる

うねりではなく体が自分を越える

間隔 20 では頭と尾の距離が平均 205.23px から 162.44px になり、隣の節の曲がりは最大 0.29 から 1.18 ラジアンへ、重なる節の組は 0 から 3 へ増えます。長くなった蛇は、輪を回るたびに自分の体の上を通ります。

折れはしないがボールのように丸まる

0.7 では体が巻ける角度が 240.64° から 481.28° に広がり、8の字の道では曲がりが最大 0.4 なので、限界に届くフレームはありません。前の節の押し込み試験を通すと頭と尾の距離は 102.32px から 18.03px に縮み、重なる組は 0 のままでも鯉は丸く巻き込みます。

のんびり見えるが足を引きずる

60 ではトカゲの腰から足までが骨の合計 42px を超える瞬間が 7% から 31% に増え、最大は 77.24px から 80.58px になります。落ち着いて見える代わりに、足がまっすぐ伸びたまま引きずられる時間が四倍あまりに延びます。

動きを減らす設定では休む姿勢で止める — 一時停止ボタンと場面説明のアクセシビリティ

場面説明と一時停止ボタン

9 場面とも canvas に role="img" と場面を言葉にした aria-label があり、スクリーンリーダーは絵の代わりに「三匹の鯉が通ったあとに波紋が広がり、底の模様がゆらぎ、ハスの葉が揺れる池。」のような説明を読み上げます。右下の「一時停止」ボタンは aria-pressed を持つふつうのボタンなので、Tab で最初にフォーカスが届き、Enter か Space で動きを止めたり再開したりできます。フォーカスが当たると 3px の外枠が出ます。

動きを減らす設定で残る一枚

OS で動きを減らす設定を有効にするとループは回らず、どの場面も生き物がゆるい S 字の休む姿勢で横たわる一枚だけを描きます。訪問の途中で設定が変わっても、change リスナーがすぐその姿勢へ移します。そのあと盤の上でポインターを動かすと目標と目だけが描き直されるので、生き物は動かないまま訪問者のほうを見ます。

08 の状態の行

状態の行があるのは 08 だけで、左上に「うろうろ中」「ついていく中」「驚いて逃げる中」が出ます。デモのあいだは aria-live が off なので、ひとりでに変わる状態をスクリーンリーダーが読み上げることはなく、訪問者が最初に操作したあとで polite に切り替わります。

サイトにマウスを追う生き物を置く前のよくある質問

スマートフォンの指でもこの生き物はついてきますか?

はい。盤は Pointer Events の pointermove と pointerdown から目標を取るので、タッチもマウスと同じに扱われます。指を離して pointerleave か pointercancel が届くと、目標は少しずつ 8の字の道へ戻ります。

この マウス 追従 アニメーション を大きな背景にするとページは重くなりますか?

canvas の内部座標は 480×300 のままで、画面の画素の倍率は 2 で打ち切るので、盤を横に広げても生き物と波のシミュレーションの計算は増えません。いちばん重い 09 でも、1 フレームに回す波の計算は 240×150 の格子で最大 4 歩で、砂と 48 個の小石は起動時に一度だけ描きます。canvas のピクセルを一つずつ計算する別の場面は、キャンバスの水滴とブラックホールの効果にまとめています。

生き物の色や太さはどこで変えますか?

色は各スクリプトの前半にある CORAL・LAVENDER・BUTTER・SAGE のような RGB の配列で、太さは 01 なら BODY_WIDTH 15、02 なら KOI_SIZE 17 です。輪郭線はどれも INK 一色なので、背景を暗くするときはその値も明るくしないと生き物が背景に沈みます。ポインターのほうへ向きを変える立体を探しているなら、ポインターを追う3Dの場面が別の側から扱っています。

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

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