このシミュレーションは、1/240秒刻みで「4つの車輪の下の地面までの長さを測る → ばねの力とタイヤの力を求める → 車を1つの剛体として動かす」をくり返している。地面の網目や車の形を立体に描き、影を落とし、視点を回すのはthree.jsの受け持ちで、記事の物理はその前に終わっている。画面に重ねた図と測った値は、計算の結果を2Dの面に重ねて描いたものだ。
長さはメートル、車はカローラの大きさ
長さはメートル、時間は秒、力はニュートンで数える。質量1,370 kg、ホイールベース2.64 m、トレッド1.515 mは、カローラのセダン(前輪駆動)の主要諸元表から取った。トレッドは前1.51 m・後1.52 mの平均である。重心の高さ0.54 mは、NHTSAがまとめた乗用車の平均のSSF(1.41)とトレッドから逆算した。
定数と単位(src/sims/car-terrain.ts の 21〜66 行)const G = 9.81; // 重力加速度 m/s²const DEG = Math.PI / 180;const KMH = 1 / 3.6; // 1 km/h = 0.278 m/s// 車: 車両重量 1,370 kg・ホイールベース 2.64 m・トレッド 1.51〜1.52 m の前輪駆動のセダン(カローラの主要諸元表)const MASS = 1370; // 質量 kgconst WB = 2.64; // ホイールベース(前と後ろの車軸の間)mconst TRACK = 1.515; // トレッド(左右のタイヤの中心の間)mconst FRONT = 0.6; // 前輪にかかる重さの割合const LA = WB * (1 - FRONT); // 重心から前の車軸まで mconst LB = WB * FRONT; // 重心から後ろの車軸まで mconst R_W = 0.316; // タイヤの半径 m(205/55R16。カローラの G のタイヤ)const INERTIA = new THREE.Vector3(500, 2300, 2100); // 慣性モーメント kg·m²(前後の軸=横に傾く・上下の軸=向きを変える・左右の軸=前後に傾く)// ばねとダンパー: 車体が 1 秒に 1.3 回上下に揺れ、2 回ほどで揺れが収まる固さconst F_RIDE = 1.3; // 車体が上下に揺れる振動数 Hzconst ZETA = 0.35; // 減衰比const S_MAX = 0.3; // ばねがいちばん伸びたときの、取り付け点から車輪の中心までの長さ mconst X_STATIC = G / (2 * Math.PI * F_RIDE) ** 2; // 止まっているときのばねの縮み m(約 0.15 m)const X_BUMP = 0.24; // これより縮むと硬いゴム(バンプストッパー)に当たる mconst K_BUMP = 3e5; // バンプストッパーの固さ N/mconst K_ARB = [14000, 7000]; // スタビライザー(左右の縮みの差に逆らうばね)前・後 N/m// タイヤconst C_ALPHA = 18; // 横の固さ(コーナリングパワー)÷ 荷重 1/rad。4,000 N の荷重で 72,000 N/radconst SIGMA = 0.4; // 緩和長 m(横の力が立ち上がるまでに転がる距離)const C_RR = 0.012; // 転がり抵抗の係数const CDA = 0.64; // 空気抵抗の係数 × 前から見た面積 m²const RHO_AIR = 1.2; // 空気の密度 kg/m³const P_MAX = 90e3; // エンジンの最大の仕事率 Wconst F_DRIVE = 5500; // 前輪を回す最大の力 Nconst B_MAX = 1.1; // ブレーキをいっぱいに踏んだときの力 ÷ 重さconst B_FRONT = 0.7; // ブレーキの力のうち前輪の割合const STEER_MAX = 0.6; // 前輪を切れる最大の角度 rad(約 34°)const K_BODY = 2e5, C_BODY = 1.2e4; // 車体が地面に当たったときに押し返すばね N/m と減衰 N·s/m(1 点あたり)// 路面の摩擦係数 μ: JAF の急ブレーキの試験で、止まるまでの距離から求めた平均の減速 ÷ 重力加速度(雪と氷は ABS が働く試験)const ROADS: Record<string, number> = { dry: 0.83, // 乾いた舗装: 時速 60 km から 17.0 m snow: 0.31, // 踏み固められた雪: 時速 40 km から 20.2 m ice: 0.075, // 凍った路面(氷盤): 時速 40 km から 84.1 m};const H_STEP = 1 / 240; // 運動を進める刻み 秒// 地形: 160 m 四方を 0.5 m の格子で持つconst WORLD = 160, N = 320, NV = N + 1, DX = WORLD / N, HALF = WORLD / 2;// 車体の前の端と後ろの端(重心から)m。全長 4.495 mconst XF = LA + 0.95, XR = -LB - 0.905;// 車体が地面に当たるか調べる 14 点(重心からの前後・地面からの高さ・左右)m。床の下の 3 点はタイヤより内側に置くconst HULL: [number, number, number][] = [];for (const z of [-0.86, 0.86]) HULL.push([XF, 0.25, z * 0.75], [XR, 0.28, z * 0.75], [0, 0.15, z * 0.65], [XF, 0.72, z], [XR, 0.85, z], [-0.75, 1.4, z * 0.78], [0.2, 1.43, z * 0.78]);
ばねは、車体が1秒に1.3回上下に揺れる固さにした。車輪1つが受け持つ質量をmcとすると、ばね定数はk=mc(2πf)2で、止まっているときの縮みはmcg/k=g/(2πf)2、約0.15 mになる。前輪と後輪で受け持つ重さは違うが、縮みはどちらも同じだ。
路面のμは、JAFの急ブレーキの試験の止まるまでの距離dから、平均の減速v2/(2d)を重力加速度で割って求めた。時速60 km(秒速16.7 m)から17.0 mで止まった乾いた舗装なら0.83になる。
地面は0.5 mごとの高さの表
地面は、160 m四方を0.5 mのますに分けた321×321個の高さで持つ。ますの中の高さは、4すみの高さから双一次の補間で求め、地面の向き(法線)は、0.35 m離れた4点の高さの差から出す。地形の外へ出た所は、端の高さのまま平らに続くとした。
地形(src/sims/car-terrain.ts の 68〜122 行)type Kind = "hill" | "down" | "kick" | "bank";/** 区間 [−a, a] ではそのまま、その外の幅 w でなめらかに傾きを 0 にする(坂の上と下を平らにする) */function ramp(u: number, a: number, w: number) { const x = Math.abs(u), sg = u < 0 ? -1 : 1; if (x <= a) return u; const d = Math.min(x - a, w); return sg * (a + d - (d * d) / (2 * w));}/** 地面の高さ m。th は坂の傾き(rad) */function terrainHeight(kind: Kind, th: number, x: number, z: number, extra: boolean): number { const t = Math.tan(th); if (kind === "hill") { // +z の向きへ下る斜面(z = −40〜40 m)。最初の画面では、右手にでこぼこ、左手に小山を足す let h = -t * ramp(z, 28, 12); if (extra) { const wx = clamp(3 - Math.abs(x - 36) / 4, 0, 1), wz = clamp(3 - Math.abs(z) / 4, 0, 1); h += 0.14 * Math.sin(x * 2.6) * Math.sin(z * 2.1) * wx * wz; h += 1.6 * Math.exp(-((x + 36) ** 2 + z * z) / 60); } return h; } if (kind === "down") return -t * ramp(x + 12, 34, 10); // +x の向きへ下る坂(x = −46〜22 m) if (kind === "kick") return x >= 0 && x <= 6 ? x * t : 0; // 長さ 6 m の上り坂の先が、段差で切れている return clamp(Math.hypot(x, z) - 24, 0, 12) * t; // バンク: 中心から 24〜36 m の輪が、外へ向かって上がる}function buildHeights(H: Float32Array, kind: Kind, th: number, extra: boolean) { for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) H[j * NV + i] = terrainHeight(kind, th, -HALF + i * DX, -HALF + j * DX, extra);}/** 格子の高さを双一次で補間する。地形の外は端の高さのまま平らに続く */function heightAt(H: Float32Array, x: number, z: number) { if (!(Math.abs(x) < 1e6) || !(Math.abs(z) < 1e6)) return 0; const fx = clamp((x + HALF) / DX, 0, N - 1e-6), fz = clamp((z + HALF) / DX, 0, N - 1e-6); const i = fx | 0, j = fz | 0, tx = fx - i, tz = fz - j, k = j * NV + i; const a = H[k], b = H[k + 1], c = H[k + NV], d = H[k + NV + 1]; return a + (b - a) * tx + (c - a) * tz + (a - b - c + d) * tx * tz;}/** 地面の向き(上向きの法線)。0.35 m 離れた 4 点の高さの差から */function normalAt(H: Float32Array, x: number, z: number, out: THREE.Vector3) { const e = 0.35; const gx = (heightAt(H, x + e, z) - heightAt(H, x - e, z)) / (2 * e), gz = (heightAt(H, x, z + e) - heightAt(H, x, z - e)) / (2 * e); return out.set(-gx, 1, -gz).normalize();}/** 指で盛る・削る: 半径 3.2 m の中を、中心ほど多く動かす */function sculpt(s: S, x: number, z: number, dh: number) { const R = 3.2; const i0 = Math.max(0, Math.floor((x - R + HALF) / DX)), i1 = Math.min(N, Math.ceil((x + R + HALF) / DX)); const j0 = Math.max(0, Math.floor((z - R + HALF) / DX)), j1 = Math.min(N, Math.ceil((z + R + HALF) / DX)); if (i0 > i1 || j0 > j1) return; for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) { const d2 = ((-HALF + i * DX - x) ** 2 + (-HALF + j * DX - z) ** 2) / (R * R); if (d2 < 1) { const k = j * NV + i; s.H[k] = clamp(s.H[k] + dh * (1 - d2) ** 2, -30, 45); } } refreshLand(s, i0, i1, j0, j1); placePosts(s, x, z, R + 0.3);}
場面ごとの地形は、式1つで書いてある。横に傾いた坂は、中ほどの56 mだけが一様な傾きで、その上と下はなめらかに平らへつながる。指で盛る・削るときは、半径3.2 mの中の高さを、中心ほど大きく動かす。変えた範囲の番号を描画に渡し、そこだけ網目を書き換える。
車輪の下へ線を下ろし、タイヤの力を摩擦円に収める
ばねの縮みは、車体のばねの取り付け点から、車体の下向きに線を下ろして測る。車輪は半径Rの円板で、地面を平面とみなすと、中心は真下の地面よりR/nyだけ上にある(nyは地面の法線の上向きの成分)。線を伸ばすと、その先の真下の地面の高さも変わる。そこで、地面の傾きを入れて長さを直す計算(ニュートン法)を3回くり返す。傾きを入れないと、急な坂で谷側のばねの縮みを小さく見積もり、車体が谷側へ沈みすぎる。段差の角に乗るときのために、車輪の前後6点の高さも調べ、円が地面に触れるいちばん高い中心の位置を使う。こうすると、丸い車輪が段差の角を転がって乗り越える。
車輪とタイヤ(src/sims/car-terrain.ts の 124〜238 行)interface Wheel { front: boolean; mb: THREE.Vector3; // ばねの取り付け点(車体に固定した座標。重心が原点・前が +x・上が +y・右が +z) k: number; c: number; // ばね定数 N/m・減衰係数 N·s/m len: number; // 取り付け点から車輪の中心までの長さ m x: number; xPrev: number; // ばねの縮み m Fs: number; // ばねとダンパーの力 N Fz: number; Fx: number; Fy: number; Fmax: number; // 地面を押す力・前後と横(左が +)のタイヤの力・摩擦円の半径 N dy: number; dx: number; // タイヤの横のたわみ・ブレーキで止まっているときの前後のたわみ m contact: boolean; sliding: boolean; locked: boolean; slipV: number; spin: number; // 車輪の回った角度 rad(絵のため) C: THREE.Vector3; P: THREE.Vector3; n: THREE.Vector3; hw: THREE.Vector3; lw: THREE.Vector3; // 中心・接点・地面の向き・タイヤの前と左 mark: THREE.Vector3; markOn: boolean; pivot: THREE.Object3D; spinner: THREE.Object3D;}const EDGE = [-0.8, -0.5, -0.25, 0.25, 0.5, 0.8]; // 段差の角を探す、車輪の前後の点(半径に対する割合)const _M = new THREE.Vector3(), _C = new THREE.Vector3(), _d = new THREE.Vector3(), _hd = new THREE.Vector3(), _fk = new THREE.Vector3();const _vc = new THREE.Vector3(), _rel = new THREE.Vector3(), _f = new THREE.Vector3(), _n = new THREE.Vector3(), _vt = new THREE.Vector3(), _tq = new THREE.Vector3(); /** ばねの縮みを測る: 取り付け点から車体の下向きに線を下ろし、車輪(半径 R_W の円板)が地面に触れる長さを求める */function measure(s: S, wh: Wheel, steer: number) { _M.copy(wh.mb).applyQuaternion(s.q).add(s.p); _d.copy(s.up).negate(); wh.contact = false; wh.x = 0; wh.len = S_MAX; wh.C.copy(_M).addScaledVector(_d, S_MAX); if (s.up.y < 0.2) return; // 大きく傾いたら車輪は地面に届かない(車体の角で支える) _fk.copy(s.fwd).multiplyScalar(Math.cos(steer)).addScaledVector(s.right, -Math.sin(steer)); _hd.set(_fk.x, 0, _fk.z); if (_hd.lengthSq() < 1e-6) _hd.set(1, 0, 0); _hd.normalize(); let len = S_MAX; for (let it = 0; it < 3; it++) { _C.copy(_M).addScaledVector(_d, len); normalAt(s.H, _C.x, _C.z, wh.n); // 地面を平面とみなすと、車輪の中心は真下の地面から R_W / n_y の高さにある let need = heightAt(s.H, _C.x, _C.z) + R_W / Math.max(0.3, wh.n.y); for (const e of EDGE) { const hh = heightAt(s.H, _C.x + _hd.x * e * R_W, _C.z + _hd.z * e * R_W) + R_W * Math.sqrt(1 - e * e); if (hh > need) need = hh; } // 中心の高さが need にそろう長さへ進める。線を伸ばすと真下の地面の高さも変わるので、地面の傾きを入れて割る // (傾きを入れないと急な坂で収まらず、谷側のばねの縮みを小さく見積もる) len += ((_C.y - need) * wh.n.y) / Math.max(0.2, s.up.dot(wh.n)); } wh.len = clamp(len, S_MAX - 0.36, S_MAX); wh.x = S_MAX - wh.len; wh.contact = wh.x > 0; wh.C.copy(_M).addScaledVector(_d, wh.len);}/** ばねとダンパーの力 F = k x + c dx/dt。スタビライザーは、同じ軸の左右がどちらも地面に触れているときだけ働く */function springs(s: S, h: number) { for (const wh of s.wheels) { if (!wh.contact) { wh.Fs = 0; wh.xPrev = 0; continue; } const xd = clamp((wh.x - wh.xPrev) / h, -4, 4); wh.Fs = wh.k * wh.x + wh.c * xd + (wh.x > X_BUMP ? K_BUMP * (wh.x - X_BUMP) : 0); wh.xPrev = wh.x; } for (let a = 0; a < 2; a++) { const L = s.wheels[a * 2], R = s.wheels[a * 2 + 1]; if (L.contact && R.contact) { const f = K_ARB[a] * (L.x - R.x); L.Fs += f; R.Fs -= f; } } for (const wh of s.wheels) wh.Fs = Math.max(0, wh.Fs);}/** タイヤの力: 横はたわみから、前後は駆動とブレーキから求め、合わせた大きさを摩擦円 μN の中に収めて、接点にかける */function tire(s: S, wh: Wheel, steer: number, drive: number, brake: number, h: number) { wh.Fx = wh.Fy = wh.Fz = wh.Fmax = 0; wh.sliding = wh.locked = false; wh.slipV = 0; if (!wh.contact) { wh.dy = 0; wh.dx = 0; return; } const n = wh.n; wh.P.copy(wh.C).addScaledVector(n, -R_W); const Fz = wh.Fs * Math.max(0, s.up.dot(n)), Fmax = s.mu * Fz; wh.Fz = Fz; wh.Fmax = Fmax; // 地面に沿ったタイヤの前(hw)と左(lw) _fk.copy(s.fwd).multiplyScalar(Math.cos(steer)).addScaledVector(s.right, -Math.sin(steer)); wh.hw.copy(_fk).addScaledVector(n, -_fk.dot(n)).normalize(); wh.lw.crossVectors(n, wh.hw); // 接点が地面に対して動く速さ v + ω × r _rel.subVectors(wh.P, s.p); _vc.crossVectors(s.w, _rel).add(s.vel); const vx = _vc.dot(wh.hw), vy = _vc.dot(wh.lw), va = Math.abs(vx); const kT = (C_ALPHA * Fz) / SIGMA; // たわみ 1 m あたりの力 N/m const cLow = Math.sqrt((kT * MASS) / 4) * Math.max(0, 1 - va / 3); // 止まりかけのときだけ効かせる減衰 // 横: たわみは横にずれる速さで増え、転がった距離 σ ごとに戻る。速いときは F = −Cα × (すべり角) に近づく wh.dy = (wh.dy + vy * h) / (1 + (va * h) / SIGMA); let Fy = -kT * wh.dy - cLow * vy; // 前後: 駆動の力と転がり抵抗とブレーキ let Fx = drive - C_RR * Fz * Math.tanh(vx / 0.3); const B = s.abs ? Math.min(brake, 0.95 * Fmax) : brake; const vpl = Math.hypot(vx, vy); if (!s.abs && B > Fmax && vpl > 0.5) { // ロック: 車輪が止まり、タイヤは接点の動きと逆の向きに μN でこすられる。横の力を別に出す余裕はない wh.locked = wh.sliding = true; wh.slipV = vpl; wh.dy = 0; wh.dx = 0; Fx = (-Fmax * vx) / vpl; Fy = (-Fmax * vy) / vpl; } else { if (B > 0) { // ブレーキ: 止まっている車輪はたわみのばねで支える(坂の途中で止まっていられる)。B を超える力は出ない wh.dx += vx * h; let Fb = -kT * wh.dx - cLow * vx; if (Math.abs(Fb) > B) { Fb = Math.sign(Fb) * B; wh.dx = -Fb / Math.max(1, kT); } Fx += Fb; } else wh.dx = 0; // 摩擦円: 前後と横を合わせた力は μN までしか出ない。超えたら向きはそのままで大きさを μN にする(すべる) const Ft = Math.hypot(Fx, Fy); if (Ft > Fmax) { const k = Fmax / Ft; Fx *= k; Fy *= k; wh.dy = -Fy / Math.max(1, kT); wh.sliding = true; wh.slipV = Math.abs(vy); } } wh.Fx = Fx; wh.Fy = Fy; // 地面から受ける力は、地面に垂直な N と、地面に沿ったタイヤの力の和。 // ばねの力を車体の上向きのまま足すと、車体が傾いた分だけ地面に沿った力が摩擦円の外で増えるので、垂直な分だけを使う _f.copy(n).multiplyScalar(Fz).addScaledVector(wh.hw, Fx).addScaledVector(wh.lw, Fy); addForce(s, _f, wh.P); wh.spin = (wh.spin + (wh.locked ? 0 : (vx / R_W) * h)) % (2 * Math.PI);}
ばねの力はF=kx+cx˙で、左右の縮みの差に逆らうスタビライザーと、縮み切ったときに当たる硬いゴムを足す。タイヤが地面を押す力Nは、この力の地面に垂直な成分である。車体に足すのは、このNと、地面に沿ったタイヤの力だけだ。ばねの力を車体の上向きのまま足すと、車体が傾いた分だけ地面に沿った力が摩擦円の外で生まれ、tanθがμに届く前に車がすべり出してしまう。
タイヤの横の力は、たわみyから求める。yは横にずれる速さvyで増え、タイヤが距離σ(緩和長)転がるごとに元へ戻る。走っているときはyがσvy/∣vx∣に落ち着き、力はCαvy/∣vx∣、つまり横すべり角に比例する力(コーナリングフォース)になる。止まっているときはyがそのままばねになるので、横に傾いた坂の途中でも車がずり落ちない。ブレーキで止まっている車輪の前後の力も、同じばねの形で求める。
最後に、前後の力と横の力を合わせた大きさをμNと比べる。超えていたら、向きはそのままで大きさをμNに縮める。本文のFx2+Fy2≤μNが、この数行にあたる。ABSが無いときにブレーキの力がμNを超えると車輪がロックし、タイヤは接点が動く向きの逆へμNでこすられるだけになる。
1つの剛体として動かす
4つの接点の力と車体の重さを足して、重心を動かす。力が重心からrだけ離れた点にかかれば、重心のまわりにr×Fのトルクも生まれる。荷重の移動を表す式は書いていない。ブレーキの力は地面の高さにかかり、重心はその0.54 m上にあるので、トルクが車体を前へ傾け、前輪のばねを縮めて前輪のNを増やす。
剛体の運動(src/sims/car-terrain.ts の 240〜310 行)/** 力 f を点 at にかける: 重心を動かす力と、重心のまわりに回す力(トルク r × f)に分けて足す */function addForce(s: S, f: THREE.Vector3, at: THREE.Vector3) { s.F.add(f); s.Fc.add(f); _rel.subVectors(at, s.p); s.T.add(_tq.crossVectors(_rel, f));}/** 車体の角が地面に入ったら、硬いばねで押し返し、こする向きと逆に摩擦をかける */function hull(s: S) { const mub = Math.min(0.5, s.mu); for (const pb of s.hullB) { _C.copy(pb).applyQuaternion(s.q).add(s.p); const gh = heightAt(s.H, _C.x, _C.z); if (_C.y > gh + 0.02) continue; normalAt(s.H, _C.x, _C.z, _n); const depth = (gh - _C.y) * _n.y; if (depth <= 0) continue; _rel.subVectors(_C, s.p); _vc.crossVectors(s.w, _rel).add(s.vel); const vn = _vc.dot(_n), Fn = Math.max(0, K_BODY * depth - C_BODY * vn); _vt.copy(_vc).addScaledVector(_n, -vn); _f.copy(_n).multiplyScalar(Fn).addScaledVector(_vt, (-mub * Fn) / Math.max(_vt.length(), 0.3)); addForce(s, _f, _C); s.bodyHit = true; }}const _ng = new THREE.Vector3(), _rg = new THREE.Vector3();const _qi = new THREE.Quaternion(), _wb = new THREE.Vector3(), _tb = new THREE.Vector3(), _Lb = new THREE.Vector3(), _gy = new THREE.Vector3();/** 速さと位置、回る速さと向きを 1 刻み進める(半陰的オイラー法: 先に速さを、新しい速さで位置を進める) */function integrate(s: S, h: number) { const { p, vel, q, w } = s; vel.addScaledVector(s.F, h / MASS); p.addScaledVector(vel, h); // 車体に固定した座標では慣性モーメントが対角になるので、オイラーの運動方程式 I dω/dt = τ − ω × (Iω) で回す速さを進める _qi.copy(q).invert(); _wb.copy(w).applyQuaternion(_qi); _tb.copy(s.T).applyQuaternion(_qi); _Lb.set(_wb.x * INERTIA.x, _wb.y * INERTIA.y, _wb.z * INERTIA.z); _gy.crossVectors(_wb, _Lb); _wb.x += (h * (_tb.x - _gy.x)) / INERTIA.x; _wb.y += (h * (_tb.y - _gy.y)) / INERTIA.y; _wb.z += (h * (_tb.z - _gy.z)) / INERTIA.z; // 置いた直後の 1 秒は、横と前後に揺れる回転を強く止める(ばねが釣り合う前の揺れで転ばないように) if (s.tPhys - s.tPlace < 1) { const d = Math.exp(-h * 6); _wb.x *= d; _wb.z *= d; } w.copy(_wb).applyQuaternion(q); // 向き(四元数): dq/dt = ½ (ω, 0) q const ax = w.x, ay = w.y, az = w.z, bx = q.x, by = q.y, bz = q.z, bw = q.w, k = 0.5 * h; q.set(bx + k * (ax * bw + ay * bz - az * by), by + k * (ay * bw + az * bx - ax * bz), bz + k * (az * bw + ax * by - ay * bx), bw - k * (ax * bx + ay * by + az * bz)).normalize();}/** 1 刻み: 運転 → 4 輪のばね → タイヤ → 車体の角 → 運動を進める */function physicsStep(s: S, api: Api, h: number) { s.fwd.set(1, 0, 0).applyQuaternion(s.q); s.up.set(0, 1, 0).applyQuaternion(s.q); s.right.set(0, 0, 1).applyQuaternion(s.q); control(s, h); s.F.set(0, -MASS * G, 0); s.T.set(0, 0, 0); s.Fc.set(0, 0, 0); s.bodyHit = false; s.F.addScaledVector(s.vel, -0.5 * RHO_AIR * CDA * s.vel.length()); for (const wh of s.wheels) measure(s, wh, wh.front ? s.delta : 0); springs(s, h); for (const wh of s.wheels) tire(s, wh, wh.front ? s.delta : 0, wh.front ? s.driveF / 2 : 0, (s.brakeF * (wh.front ? B_FRONT : 1 - B_FRONT)) / 2, h); hull(s); integrate(s, h); s.tPhys += h; // 地面から受ける力を、タイヤが触れている地面の横と上に分ける。横 ÷ 上 が μ を超えるとすべり、SSF を超えると転ぶ _ng.set(0, 0, 0); for (const wh of s.wheels) if (wh.contact) _ng.add(wh.n); if (_ng.lengthSq() < 1e-6) _ng.copy(s.up); else _ng.normalize(); _rg.copy(s.right).addScaledVector(_ng, -s.right.dot(_ng)).normalize(); const kf = Math.min(1, h / 0.12); s.latF += (s.Fc.dot(_rg) - s.latF) * kf; s.vertF += (s.Fc.dot(_ng) - s.vertF) * kf; s.roll += (Math.asin(clamp(s.up.dot(_rg), -1, 1)) - s.roll) * kf; // ばねで車体が地面に対して横に傾いた角度(右が下がると +) s.ratio = s.vertF > 0.2 * MASS * G ? Math.abs(s.latF) / s.vertF : NaN; events(s, api, h);}
回る速さは、車体に固定した座標で進める。この座標では慣性モーメントが3つの数で表せるので、オイラーの運動方程式Iω˙=τ−ω×Iωをそのまま使える。向きは四元数で持ち、q˙=21(ω,0)qで進めてから長さを1に戻す。
刻みを1/240秒にしたのは、タイヤのたわみのばねが1輪で18万 N/mほどと硬く、刻みが粗いと計算が暴れるからである。横転して車体が地面に当たったときは、車体の角など14点を硬いばねで押し返す。場面の初めに車を置くと、4輪のばねは同じだけ縮んだ形から始まる。坂ではばねが支える重さがcosθ倍になるので、縮みもそのぶん少なくして置く。それでも左右の重さが釣り合うまでに車体が揺れ、それだけで転ぶことがあるので、置いた直後の1秒だけは横と前後に揺れる回転を強く弱めている。横に傾いた坂をまっすぐ走る場面では、タイヤが坂を支えるたわみも先に入れ、車体をそのすべり角だけ山側へ向けてから走り出す。こうしないと、置いた瞬間にタイヤがすべり、μより緩い坂でも谷へ流れ出してしまう。
測った値の「横の力 ÷ 支える力」は、4輪が触れている地面の向きを基準にして、地面から受ける力を横と上に分けた比である。ばねで車体が傾いた分は、画面に重ねた図で車体を回して描いている。
決まった操作と、手で運転する操作
場面の決まった操作は、道すじの少し先の点を狙ってハンドルを切る「純追従」という方法で走る。先の点が車から見て角度α、距離ldにあるとき、前輪をarctan(2Lsinα/ld)だけ切る(Lはホイールベース)。速さは、つまみの値との差に比例してアクセルとブレーキを踏む。場面ごとに、決まった位置でブレーキを踏んだり、左右に急ハンドルを切ったりする。
運転(src/sims/car-terrain.ts の 312〜430 行)interface Program { path: "line" | "circle"; ax: number; az: number; R: number; kind: string; phase: number; t0: number }type PadKey = "left" | "right" | "brake" | "accel";const SWERVE = 0.22; // 急ハンドルで前輪を切る角度 rad(約 13°)/** 急ハンドル: 左へ切って 0.6 秒保ち、右へ切り返して 2 秒保つ(前輪の角度 rad) */function fishhook(t: number) { if (t < 0.25) return (SWERVE * t) / 0.25; if (t < 0.85) return SWERVE; if (t < 1.25) return SWERVE * (1 - (2 * (t - 0.85)) / 0.4); if (t < 3.2) return -SWERVE; return -SWERVE * Math.max(0, 1 - (t - 3.2) / 0.8);}/** 決まった操作: 道すじ(直線か円)の少し先の点へ向けてハンドルを切り(純追従)、速さを保つ。場面ごとにブレーキや急ハンドルを足す */function autopilot(s: S, v: number): [number, number, number] { const P = s.prog, p = s.p, ld = clamp(0.8 * Math.abs(v) + 4, 5, 16); let tx: number, tz: number; if (P.path === "circle") { const a = Math.atan2(p.z - P.az, p.x - P.ax) + ld / P.R; tx = P.ax + P.R * Math.cos(a); tz = P.az + P.R * Math.sin(a); } else { tx = p.x + ld; tz = P.az; } const fl = Math.hypot(s.fwd.x, s.fwd.z) || 1, hx = s.fwd.x / fl, hz = s.fwd.z / fl; const rx = tx - p.x, rz = tz - p.z, a = Math.atan2(rx * hz - rz * hx, rx * hx + rz * hz); let steer = clamp(Math.atan((2 * WB * Math.sin(a)) / ld), -STEER_MAX, STEER_MAX); let thr = clamp((s.vT - v) * 0.6, 0, 1), brk = clamp((v - s.vT - 0.5) * 0.4, 0, 0.6); const t = s.tPhys; if (P.phase === 0) { if ((P.kind === "slope" && p.x > 50) || (P.kind === "ice" && p.x > -24) || (P.kind === "brake" && p.x > -26) || (P.kind === "jump" && p.x > 26)) P.phase = 1; if (P.kind === "swerve" && p.x > -32) { P.phase = 1; P.t0 = t; } } if (P.phase === 1) { if (P.kind === "swerve" && t - P.t0 < 4) { steer = fishhook(t - P.t0); thr = 0; brk = 0; } else { thr = 0; brk = P.kind === "brake" ? 1 : P.kind === "ice" ? 0.5 : 0.6; } } return [thr, brk, steer];}/** 運転: 手で運転しているときはキーと画面のボタン、そうでなければ決まった操作。ハンドルは少しずつ切る */function control(s: S, h: number) { const v = s.vel.dot(s.fwd); let thr = 0, brk = 0, steer = 0; if (s.manual) { const up = s.keys.up || s.pad.accel, dn = s.keys.down || s.pad.brake; if (up && dn) brk = 1; else if (up) { if (v < -0.5) brk = 1; else thr = 1; } else if (dn) { if (v > 0.5) brk = 1; else thr = -0.5; } // 止まってから押し続けると後ろへ進む else if (Math.abs(v) < 0.4) brk = 0.35; // 止まったら、ブレーキで止めておく const st = (s.keys.left || s.pad.left ? 1 : 0) - (s.keys.right || s.pad.right ? 1 : 0); steer = (st * STEER_MAX) / (1 + (v / 8) ** 2); // 速いほど小さく切る } else [thr, brk, steer] = autopilot(s, v); const rate = s.manual ? 1.6 : 5; s.delta += clamp(steer - s.delta, -rate * h, rate * h); s.driveF = thr * Math.min(F_DRIVE, P_MAX / Math.max(Math.abs(v), 1)) * (Math.abs(v) > 34 && thr * v > 0 ? 0 : 1); s.brakeF = brk * B_MAX * MASS * G;}function takeOver(api: Api, s: S) { if (s.manual) return; s.manual = true; api.note("手で運転しています。場面のボタンを押すと、決まった操作に戻ります", 2600);}/** 画面の下の運転のボタンの位置(左右のハンドルとブレーキ・アクセル) */function padRects(api: Api): Record<PadKey, [number, number, number, number]> { const P = api.u(56), m = api.u(14), g = api.u(8), y = api.h - m - P; return { left: [m, y, P, P], right: [m + P + g, y, P, P], brake: [api.w - m - 2 * P - g, y, P, P], accel: [api.w - m - P, y, P, P] };}/** 運転のボタンは描画面に直接付け、視点を回す操作より先に受ける(2 本の指でハンドルとアクセルを同時に押せる) */function bindPads(api: Api, s: S) { const el = api.ctx.canvas; const at = (e: PointerEvent) => { const b = el.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; }; const which = (x: number, y: number): PadKey | null => { const r = padRects(api); for (const k of ["left", "right", "brake", "accel"] as PadKey[]) { const [a, b, w, h] = r[k]; if (x >= a && x <= a + w && y >= b && y <= b + h) return k; } return null; }; const down = (e: PointerEvent) => { const [x, y] = at(e), k = which(x, y); if (!k) return; e.stopImmediatePropagation(); e.preventDefault(); try { el.setPointerCapture(e.pointerId); } catch { /* 取れなくても押した状態は保つ */ } s.padIds.set(e.pointerId, k); s.pad[k] = true; takeOver(api, s); }; const move = (e: PointerEvent) => { if (s.padIds.has(e.pointerId)) e.stopImmediatePropagation(); }; const up = (e: PointerEvent) => { const k = s.padIds.get(e.pointerId); if (!k) return; e.stopImmediatePropagation(); s.padIds.delete(e.pointerId); s.pad[k] = [...s.padIds.values()].includes(k); }; el.addEventListener("pointerdown", down, true); el.addEventListener("pointermove", move, true); el.addEventListener("pointerup", up, true); el.addEventListener("pointercancel", up, true); return () => { el.removeEventListener("pointerdown", down, true); el.removeEventListener("pointermove", move, true); el.removeEventListener("pointerup", up, true); el.removeEventListener("pointercancel", up, true); };}/** 出来事の知らせ: 片側の 2 輪が浮く・横転・横すべり・ロック・4 輪が浮く・着地 */function events(s: S, api: Api, h: number) { const ev = s.ev, W = s.wheels; let n = 0, nsl = 0; for (const w of W) { if (w.contact) n++; if (w.sliding && !w.locked) nsl++; } const say = (t: string, ms = 2400) => { api.note(t, ms); ev.last = s.tPhys; }; if (s.up.y < 0.3) { if (!ev.roll) { ev.roll = true; say("横転しました", 3000); } } else if (n === 2 && W[0].contact === W[2].contact && W[0].contact !== W[1].contact && !ev.lift) { ev.lift = true; say(`${W[0].contact ? "右" : "左"}側の2輪が地面を離れました。重さがすべて反対側の2輪にかかっています`, 3000); } if (n === 0 && !s.bodyHit) s.air += h; else { if (s.air > 0.2 && !ev.roll) { ev.land = 0.4; ev.peak = 0; ev.flight = s.air; } // 横転したあとの跳ね返りは知らせない s.air = 0; } if (ev.land > 0) { for (const w of W) ev.peak = Math.max(ev.peak, w.Fz / ((MASS * G * (w.front ? FRONT : 1 - FRONT)) / 2)); ev.land -= h; if (ev.land <= 0) say(`${fmt(ev.flight, 2)}秒飛んで着地。いちばん重い車輪に、止まっているときの${fmt(ev.peak, 1)}倍の重さがかかりました`, 3600); } const vlat = Math.abs(s.vel.dot(s.right)); ev.slide = clamp(ev.slide + (vlat > 0.8 && nsl >= 2 ? h : -h), 0, 1); if (ev.slide > 0.3 && s.tPhys - ev.slideT > 6 && s.tPhys - ev.last > 1.2 && s.up.y > 0.5) { ev.slideT = s.tPhys; say("タイヤの力が摩擦円の端に届き、車が横へすべっています"); } if (!ev.lock && (W[0].locked || W[1].locked || W[2].locked || W[3].locked)) { ev.lock = true; say("ブレーキで車輪がロックしました。タイヤはこすられる向きにしか力を出せません", 3000); } if (!ev.scrape && s.bodyHit && s.up.y > 0.7 && n > 0) { ev.scrape = true; if (s.tPhys - ev.last > 0.8) say("車体の底が地面に当たりました"); }}
キーか画面のボタンを押すと、手で運転する操作に切り替わる。画面のボタンは、視点を回す操作より先に指を受けるように描画面に付けてあるので、2本の指でハンドルとアクセルを同時に押せる。速いほどハンドルを小さく切るのは、キーでの運転で急に曲がりすぎないためだ。
three.jsが受け持つ描画
地面は、three.jsの平面を高さの表のとおりに曲げた網目で描く。画質が低いときは1 mのますに粗くするが、計算に使う高さはいつも0.5 mのままだ。車体は横から見た形を押し出して作り、車輪の部分は、ハンドルの角度で回る部品と、転がる角度で回る部品の2段に分けてある。
three.jsで描く(src/sims/car-terrain.ts の 432〜580 行)interface Land { mesh: THREE.Mesh; geo: THREE.BufferGeometry; lines: THREE.LineSegments; lineH: Int32Array; step: number; n: number }/** 地面の網目(画質 0 は 1 m、それ以外は 0.5 m のます)と、5 m ごとの線を作る。計算に使う高さはいつも 0.5 m の格子 */function makeLand(api: Api, s: S) { if (s.land) { s.v.scene.remove(s.land.mesh, s.land.lines); s.land.geo.dispose(); s.land.lines.geometry.dispose(); } const step = api.quality === 0 ? 2 : 1, n = N / step; const g = heightGrid(n, n, WORLD, WORLD, s.mats.land); g.mesh.frustumCulled = false; const L = WORLD / 5 + 1, nv = L * (n + 1) * 2, lp = new Float32Array(nv * 3), lineH = new Int32Array(nv), idx: number[] = []; let k = 0; for (let dir = 0; dir < 2; dir++) for (let l = 0; l < L; l++) for (let a = 0; a <= n; a++) { const i = dir === 0 ? a * step : l * 10, j = dir === 0 ? l * 10 : a * step; lineH[k] = j * NV + i; lp[k * 3] = -HALF + i * DX; lp[k * 3 + 2] = -HALF + j * DX; if (a > 0) idx.push(k - 1, k); k++; } const lg = new THREE.BufferGeometry(); lg.setAttribute("position", new THREE.BufferAttribute(lp, 3)); lg.setIndex(idx); const lines = new THREE.LineSegments(lg, s.mats.line); lines.frustumCulled = false; s.land = { mesh: g.mesh, geo: g.geo, lines, lineH, step, n }; s.v.scene.add(g.mesh, lines); refreshLand(s, 0, N, 0, N);}/** 高さが変わった範囲(格子の番号)だけ、網目の頂点と面の向き、線を書き換える */function refreshLand(s: S, i0: number, i1: number, j0: number, j1: number) { const L = s.land; if (!L) return; const st = L.step, n = L.n, H = s.H; const pos = L.geo.attributes.position as THREE.BufferAttribute, nor = L.geo.attributes.normal as THREE.BufferAttribute; const a0 = Math.max(0, Math.floor(i0 / st) - 1), a1 = Math.min(n, Math.ceil(i1 / st) + 1); const b0 = Math.max(0, Math.floor(j0 / st) - 1), b1 = Math.min(n, Math.ceil(j1 / st) + 1); const hv = (a: number, b: number) => H[clamp(b, 0, n) * st * NV + clamp(a, 0, n) * st], d2 = 2 * st * DX; for (let b = b0; b <= b1; b++) for (let a = a0; a <= a1; a++) { const k = b * (n + 1) + a, gx = (hv(a + 1, b) - hv(a - 1, b)) / d2, gz = (hv(a, b + 1) - hv(a, b - 1)) / d2, l = 1 / Math.hypot(gx, 1, gz); pos.setY(k, hv(a, b)); nor.setXYZ(k, -gx * l, l, -gz * l); } const r0 = b0 * (n + 1) * 3, cnt = (b1 - b0 + 1) * (n + 1) * 3; pos.addUpdateRange(r0, cnt); nor.addUpdateRange(r0, cnt); pos.needsUpdate = true; nor.needsUpdate = true; const la = L.lines.geometry.attributes.position as THREE.BufferAttribute, arr = la.array as Float32Array; for (let k = 0; k < L.lineH.length; k++) { const hi = L.lineH[k], i = hi % NV, j = (hi / NV) | 0; if (i >= i0 - 2 && i <= i1 + 2 && j >= j0 - 2 && j <= j1 + 2) arr[k * 3 + 1] = H[hi] + 0.05; } la.needsUpdate = true;}/** 車の形: 横から見た形を押し出した車体(左右のタイヤの間)・車輪の所を切り欠いたフェンダー・客室・4 つの車輪 */function makeCar(s: S) { const car = new THREE.Group(), shell = new THREE.Group(); car.add(shell); shell.position.y = -s.h; const ext = (pts: [number, number][], depth: number, z0: number, mat: THREE.Material) => { const g = new THREE.ExtrudeGeometry(new THREE.Shape(pts.map(([x, y]) => new THREE.Vector2(x, y))), { depth, bevelEnabled: false, curveSegments: 4 }); g.translate(0, 0, z0); const m = new THREE.Mesh(g, mat); m.castShadow = true; m.receiveShadow = true; shell.add(m); }; const top: [number, number][] = [[XF, 0.32], [XF, 0.62], [XF - 0.2, 0.74], [0.85, 0.9], [XR + 0.3, 0.93], [XR, 0.8], [XR, 0.36]]; ext([[XR + 0.2, 0.17], [XF - 0.25, 0.17], ...top], 1.3, -0.65, s.mats.body); const fender: [number, number][] = [[XR + 0.2, 0.17]]; for (const cx of [-LB, LA]) { fender.push([cx - 0.39, 0.17]); for (let i = 0; i <= 10; i++) { const a = Math.PI - (i / 10) * Math.PI; fender.push([cx + 0.39 * Math.cos(a), 0.31 + 0.39 * Math.sin(a)]); } fender.push([cx + 0.39, 0.17]); } fender.push([XF - 0.25, 0.17], ...top); ext(fender, 0.2225, 0.65, s.mats.body); ext(fender, 0.2225, -0.8725, s.mats.body); ext([[-1.0, 0.9], [0.85, 0.9], [0.15, 1.43], [-0.72, 1.4]], 1.36, -0.68, s.mats.glass); const tireG = new THREE.CylinderGeometry(R_W, R_W, 0.205, 20, 1); tireG.rotateX(Math.PI / 2); const capG = new THREE.BoxGeometry(0.44, 0.08, 0.215); for (const wh of s.wheels) { const t = new THREE.Mesh(tireG, s.mats.tire); t.castShadow = true; wh.spinner.add(t, new THREE.Mesh(capG, s.mats.cap)); wh.pivot.add(wh.spinner); car.add(wh.pivot); } s.v.scene.add(car); return { car, shell };}/** 道の両側の目印の柱(1.5 m)。真上を向いて立つので、車体の傾きと比べられる */function makePosts(s: S) { if (s.posts) { s.v.scene.remove(s.posts); s.posts.geometry.dispose(); } const xz: number[] = [], key = s.scene; if (key === "circle" || key === "bank") { const [r1, r2, n] = key === "bank" ? [22, 38, 18] : [9, 19, 12]; for (const r of [r1, r2]) for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; xz.push(r * Math.cos(a), r * Math.sin(a)); } } else for (let x = -70; x <= 70; x += 10) { xz.push(x, -6); if (key !== "brake" && key !== "jump") xz.push(x, 6); } // 横から見る場面は、カメラの側に柱を立てない const g = new THREE.BoxGeometry(0.12, 1.5, 0.12); g.translate(0, 0.75, 0); s.posts = new THREE.InstancedMesh(g, s.mats.post, xz.length / 2); s.posts.castShadow = true; s.posts.frustumCulled = false; s.postXZ = xz; s.v.scene.add(s.posts); placePosts(s, 0, 0, 1e9);}const _pm = new THREE.Matrix4();function placePosts(s: S, x: number, z: number, r: number) { if (!s.posts) return; for (let i = 0; i < s.postXZ.length / 2; i++) { const px = s.postXZ[i * 2], pz = s.postXZ[i * 2 + 1]; if ((px - x) ** 2 + (pz - z) ** 2 > r * r) continue; s.posts.setMatrixAt(i, _pm.makeTranslation(px, heightAt(s.H, px, pz), pz)); } s.posts.instanceMatrix.needsUpdate = true;}const SKID = 2400; // タイヤの跡として残す四角の数/** すべったタイヤの跡: 地面に貼った細い四角を、古いものから順に上書きする */function addSkid(s: S, a: THREE.Vector3, b: THREE.Vector3, n: THREE.Vector3) { _vt.subVectors(b, a); _d.crossVectors(_vt, n); // 進む向き × 上 = 右。三角形を上から見て左回りに並べる(表の面を上へ) if (_d.lengthSq() < 1e-8) return; _d.setLength(0.09); const k = s.skidN % SKID, arr = s.skidPos, o = k * 18, y = 0.03; const P = [a.x - _d.x, a.y - _d.y + y, a.z - _d.z, a.x + _d.x, a.y + _d.y + y, a.z + _d.z, b.x + _d.x, b.y + _d.y + y, b.z + _d.z, a.x - _d.x, a.y - _d.y + y, a.z - _d.z, b.x + _d.x, b.y + _d.y + y, b.z + _d.z, b.x - _d.x, b.y - _d.y + y, b.z - _d.z]; for (let i = 0; i < 18; i++) arr[o + i] = P[i]; const at = s.skid.geometry.attributes.position as THREE.BufferAttribute; at.addUpdateRange(o, 18); at.needsUpdate = true; s.skidN++; s.skid.geometry.setDrawRange(0, Math.min(s.skidN, SKID) * 6);}function skids(s: S) { for (const wh of s.wheels) { if (!wh.contact || wh.slipV < 1.2) { wh.markOn = false; continue; } if (!wh.markOn) { wh.mark.copy(wh.P); wh.markOn = true; continue; } if (wh.mark.distanceToSquared(wh.P) > 0.09) { addSkid(s, wh.mark, wh.P, wh.n); wh.mark.copy(wh.P); } }}/** 剛体の位置と向き、ばねの長さ・ハンドルの角度・車輪の回転を、絵の部品に写す */function syncCar(s: S) { s.car.position.copy(s.p); s.car.quaternion.copy(s.q); for (const wh of s.wheels) { wh.pivot.position.copy(wh.mb); wh.pivot.position.y -= wh.len; wh.pivot.rotation.y = wh.front ? s.delta : 0; wh.spinner.rotation.z = -wh.spin; }}const CAB_Q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-0.06, -Math.PI / 2, 0, "YXZ"));const _e = new THREE.Vector3();/** 視点: ふだんは車を追う。運転席から見るときは、目の位置にカメラを置いて車体と一緒に傾ける */function updateCamera(api: Api, s: S) { const o = s.v.orbit; if (api.bool("cab")) { o.enabled = false; s.v.fov = 70; _e.set(-0.3, 1.12 - s.h, 0.36).applyQuaternion(s.q).add(s.p); s.v.camera.position.copy(_e); s.v.camera.quaternion.copy(s.q).multiply(CAB_Q); return; } o.enabled = true; s.v.fov = 45; const k = s.camSnap ? 1 : 1 - Math.exp(-api.dt * 4); if (s.cam.follow) o.target.lerp(_e.set(s.p.x, s.p.y + 0.3, s.p.z), k); if (s.cam.yaw === null && api.t - s.lastDrag > 5) { const want = Math.atan2(-s.fwd.x, -s.fwd.z); o.yaw += wrapAngle(want - o.yaw) * (s.camSnap ? 1 : 1 - Math.exp(-api.dt * 1.5)); } s.camSnap = false;}
すべったタイヤの跡は、地面に細い四角を貼って残す。四角を入れる配列は最初に作っておき、古い跡から順に上書きするので、毎コマ部品を作ることはない。白黒の網点に直す仕組みは、プログラムのページで説明している。
画面に重ねた図
上から見た図の円の半径は、そのタイヤが地面を押す力Nに比例する。矢印は、タイヤが今出している力で、円の半径をμNとみなした長さで描くので、矢印の先が円に届けば、そのタイヤはすべっている。後ろから見た図では、重心から見かけの重力の向きへ線を伸ばし、地面と交わる点が左右のタイヤの外に出たら、線を太くする。
重さと摩擦円の図(src/sims/car-terrain.ts の 582〜694 行)/** 重心の印(4 つに分けた円の 2 つを塗る) */function cgMark(api: Api, x: number, y: number, r: number) { api.circle(x, y, r, { fill: "paper", stroke: "ink", w: api.u(1.2) }); for (const q of [0, 2]) { const pts: Pt[] = [[x, y]]; for (let i = 0; i <= 8; i++) { const a = ((q + i / 8) * Math.PI) / 2; pts.push([x + r * Math.cos(a), y + r * Math.sin(a)]); } api.poly(pts, { fill: "ink", stroke: false, close: true }); }}/** 重ねて描く図(ふだんは左上): 上から見た 4 輪の摩擦円とタイヤの力、後ろから見た見かけの重力、横の力の割合のものさし */function drawPanel(api: Api, s: S) { // 画面全体に広げたときは、上の中央に出る知らせと重ならないようにする(横長は少し下げ、縦長は運転のボタンの上に置く) const u = api.u, x0 = u(12), W = Math.min(api.w - u(24), u(330)), H = u(212), half = W / 2, fs = u(11); const y0 = !api.full ? u(12) : api.h > api.w * 1.3 ? padRects(api).left[1] - u(36) - H : Math.max(u(12), 58); api.rect(x0, y0, W, H, { fill: "paper", stroke: "ink", w: u(1) }); api.text("タイヤの力(上から)", x0 + u(10), y0 + u(17), { size: fs }); api.text("見かけの重力(後ろから)", x0 + half + u(6), y0 + u(17), { size: fs }); const areaH = H - u(22) - u(62), cx = x0 + half / 2, cy = y0 + u(24) + areaH / 2, dx = u(34), dy = u(37); // 上から: 円の半径は μN(地面を押す力 N に比例)、矢印はタイヤが出している力。前が上 const ref = (MASS * G) / 4, kf = u(15) / (Math.max(s.mu, 1e-3) * ref); api.rect(cx - dx * 0.62, cy - dy * 1.5, dx * 1.24, dy * 2.85, { stroke: "ink", w: u(1), dash: [u(3), u(3)] }); s.wheels.forEach((wh, i) => { const wx = cx + (i % 2 ? dx : -dx), wy = cy + (i < 2 ? -dy : dy), st = wh.front ? s.delta : 0; const r = Math.min(u(30), (u(15) * wh.Fz) / ref); const c = Math.cos(st), sn = Math.sin(st), tw = u(3), tl = u(8); api.poly([[wx - tw * c - tl * sn, wy - tl * c + tw * sn], [wx + tw * c - tl * sn, wy - tl * c - tw * sn], [wx + tw * c + tl * sn, wy + tl * c - tw * sn], [wx - tw * c + tl * sn, wy + tl * c + tw * sn]], { fill: "ink", stroke: false, close: true }); if (r > u(1)) api.circle(wx, wy, r, { stroke: "ink", w: wh.sliding ? u(2.6) : u(1), dash: wh.locked ? [u(3), u(3)] : undefined }); const ff = wh.Fx * c - wh.Fy * sn, fl = wh.Fx * sn + wh.Fy * c; if (Math.hypot(ff, fl) * kf > u(3)) api.arrow(wx, wy, wx - fl * kf, wy - ff * kf, { w: u(1.8), head: u(6) }); const ty = i < 2 ? Math.max(y0 + u(32), wy - r - u(4)) : Math.min(y0 + u(24) + areaH + u(4), wy + r + u(12)); api.text(wh.contact ? fmt(wh.Fz / G, 0) : "浮", wx, ty, { size: u(10.5), align: "center", font: "mono" }); }); const vf = s.vel.dot(s.fwd), vl = -s.vel.dot(s.right), sp = Math.hypot(vf, vl); if (sp > 0.5) api.arrow(cx, cy, cx - (vl / sp) * u(24), cy - (vf / sp) * u(24), { w: u(1.2), head: u(5), dash: [u(2), u(2)] }); // 後ろから: 地面を水平に描く。車体はばねで傾いた分だけ回し、重心から見かけの重力(地面から受ける力の逆向き)を伸ばす const k2 = Math.min((half * 0.6) / 1.75, (areaH * 0.6) / 1.45), c2 = x0 + half + half / 2, yb = y0 + u(24) + areaH - u(4); const X = (m: number) => c2 + m * k2, Y = (m: number) => yb - m * k2; // 片側の 2 輪が浮いたら、地面に残った側のタイヤを軸にして車ごと回す const WH = s.wheels, liftL = !WH[0].contact && !WH[2].contact, liftR = !WH[1].contact && !WH[3].contact; const px = liftL !== liftR ? (liftL ? TRACK / 2 : -TRACK / 2) : 0, cr = Math.cos(s.roll), sr = Math.sin(s.roll); const B = (x: number, y: number): Pt => [X(px + (x - px) * cr + y * sr), Y(y * cr - (x - px) * sr)]; api.line(X(-1.15), yb, X(1.15), yb, { w: u(1) }); api.poly([B(-0.87, 0.17), B(0.87, 0.17), B(0.87, 0.88), B(0.68, 0.9), B(0.55, 1.43), B(-0.55, 1.43), B(-0.68, 0.9), B(-0.87, 0.88)], { fill: "paper", stroke: "ink", w: u(1), close: true }); for (const sg of [-1, 1]) { const c = (sg * TRACK) / 2; api.poly([B(c - 0.1, 0), B(c + 0.1, 0), B(c + 0.1, 2 * R_W), B(c - 0.1, 2 * R_W)], { fill: "ink", stroke: false, close: true }); } const [gx, gy] = B(0, s.h), [ax, ay] = B(-TRACK / 2, 0), [bx, by] = B(TRACK / 2, 0); api.line(gx, gy, ax, ay, { w: u(1), dash: [u(3), u(3)] }); api.line(gx, gy, bx, by, { w: u(1), dash: [u(3), u(3)] }); if (Number.isFinite(s.ratio)) { const L = Math.hypot(s.latF, s.vertF), dr = -s.latF / L, du = -s.vertF / L; const xg = (gx - c2) / k2, yg = (yb - gy) / k2, xh = clamp(xg + (dr * yg) / -du, -1.6, 1.6), tip = Math.abs(xh) > TRACK / 2; api.line(gx, gy, X(xh), yb, { w: tip ? u(2.4) : u(1.2) }); api.arrow(gx, gy, gx + dr * u(30), gy - du * u(30), { w: u(2), head: u(7) }); api.dot(X(xh), yb, u(3)); api.text(`${fmt(Math.atan(s.ratio) / DEG, 0)}°`, gx + (dr > 0 ? -u(9) : u(9)), gy - u(9), { size: u(11), align: dr > 0 ? "right" : "left" }); } else api.text("宙に浮いている", c2, Y(1.7), { size: u(10.5), align: "center" }); cgMark(api, gx, gy, u(5)); // ものさし: 横の力 ÷ 支える力。μ を超えるとすべり、SSF を超えると転ぶ const ssf = TRACK / (2 * s.h), top = Math.max(1.6, ssf * 1.08), gx0 = x0 + u(12), gx1 = x0 + W - u(12), ga = y0 + H - u(34); const GX = (r: number) => gx0 + (clamp(r, 0, top) / top) * (gx1 - gx0); api.text(`横の力 ÷ 支える力 ${Number.isFinite(s.ratio) ? fmt(s.ratio, 2) : "-"}`, gx0, ga - u(9), { size: fs }); api.line(gx0, ga, gx1, ga, { w: u(1) }); for (let r = 0; r <= top; r += 0.5) api.line(GX(r), ga - u(3), GX(r), ga + u(3), { w: u(1) }); if (Number.isFinite(s.ratio)) api.line(gx0, ga, GX(s.ratio), ga, { w: u(5) }); const xm = GX(s.mu), xs = GX(ssf), two = Math.abs(xm - xs) < u(78); api.line(xm, ga - u(7), xm, ga + u(7), { w: u(2) }); api.line(xs, ga - u(7), xs, ga + u(7), { w: u(2) }); const lab = (t: string, x: number, y: number) => api.text(t, clamp(x, gx0 + u(34), gx1 - u(34)), y, { size: u(10.5), align: "center" }); lab(`すべる μ ${fmt(s.mu, s.mu < 0.1 ? 3 : 2)}`, xm, ga + u(17)); lab(`転ぶ SSF ${fmt(ssf, 2)}`, xs, ga + u(two ? 29 : 17));}/** 運転のボタン: 押している間は塗る */function drawPads(api: Api, s: S) { const r = padRects(api), u = api.u; const on = (k: PadKey) => s.pad[k] || (k === "left" ? s.keys.left : k === "right" ? s.keys.right : k === "brake" ? s.keys.down : s.keys.up); for (const k of ["left", "right", "brake", "accel"] as PadKey[]) { const [x, y, w, h] = r[k], o = on(k), col = o ? "paper" : "ink"; api.rect(x, y, w, h, { fill: o ? "ink" : "paper", stroke: "ink", w: u(1.4) }); if (k === "left" || k === "right") { const sg = k === "left" ? -1 : 1, mx = x + w / 2, my = y + h / 2, a = w * 0.2; api.poly([[mx + sg * a, my], [mx - sg * a, my - a], [mx - sg * a, my + a]], { fill: col, stroke: false, close: true }); } else api.text(k === "brake" ? "ブレーキ" : "アクセル", x + w / 2, y + h / 2 + u(4), { size: u(11), align: "center", color: col }); } api.label(s.manual ? "手で運転" : "決まった操作", r.accel[0] + r.accel[2], r.accel[1] - u(9), { size: u(11), align: "right" });}function drawBrush(api: Api, s: S) { const b = s.brush, tool = api.str("tool"); if (tool === "view" || !b.on || api.bool("cab") || !(api.pointer.inside || b.down)) return; const pts: Pt[] = []; for (let k = 0; k <= 32; k++) { const a = (k / 32) * Math.PI * 2, x = b.x + 3.2 * Math.cos(a), z = b.z + 3.2 * Math.sin(a); const q = s.v.project([x, heightAt(s.H, x, z) + 0.05, z]); if (q.on) pts.push([q.x, q.y]); } if (pts.length > 2) api.poly(pts, { w: api.u(1.8), dash: [api.u(5), api.u(4)] }); const c = s.v.project([b.x, heightAt(s.H, b.x, b.z), b.z]); if (c.on) api.label(tool === "raise" ? "盛る" : "削る", c.x, c.y, { size: api.u(12), align: "center", baseline: "middle" });}function overlay(api: Api, s: S) { if (!api.bool("cab")) { const c = s.v.project(s.p); if (c.on) cgMark(api, c.x, c.y, api.u(6)); } drawBrush(api, s); drawPanel(api, s); drawPads(api, s);}function readout(api: Api, s: S) { const W = s.wheels, kg = (w: Wheel) => fmt(w.Fz / G, 0); const roll = Math.asin(clamp(s.right.y, -1, 1)) / DEG, pitch = Math.asin(clamp(s.fwd.y, -1, 1)) / DEG; api.readout([ `速さ ${fmt(Math.abs(s.vel.dot(s.fwd)) / KMH, 0)} km/h`, `前輪の重さ 左 ${kg(W[0])}・右 ${kg(W[1])} kg`, `後輪の重さ 左 ${kg(W[2])}・右 ${kg(W[3])} kg`, `横の力 ÷ 支える力 ${Number.isFinite(s.ratio) ? fmt(s.ratio, 2) : "-"}(μ ${fmt(s.mu, s.mu < 0.1 ? 3 : 2)}・SSF ${fmt(TRACK / (2 * s.h), 2)})`, `車体の傾き 横 ${fmt(Math.abs(roll), 1)}°・前後 ${fmt(Math.abs(pitch), 1)}°`, ]);}