このシミュレーションは、1コマごとに、決まった刻み(0.1秒)で船の運動を何回か進める。刻みのたびに、船は「どちらへ進むか」を判断して舵を命じ、舵取機と野本の式がその舵に遅れてついていく。最後に、今の判断のまま進んだらどうなるかを船の写しで先に計算し、海図・引き波・岩・船・計器を描く。時間は場面ごとに4〜14倍に早めている。
船の数は表から
船の性質は、本文の表にあるK(1/秒)とT(秒)と、その値を測ったときの速力をそのまま持つ。使うときは、長さと速さで割ったK′=KL/UとT′=TU/Lに直してから、今の速さUのK=K′U/L、T=T′L/Uを計算する。止まった船では舵が効かないように、Kには今の速さをそのまま掛けている。
定数と船(src/sims/ship-obstacles.ts の 23〜61 行)const KN = 0.514444; // 1 ノット = 0.514 m/秒const GRAV = 9.81; // 重力の加速度(m/秒²)const DEG = Math.PI / 180;export const RUDDER_MAX = 35 * DEG; // 舵角の最大(左右 35°)const H = 0.1; // 運動を進める刻み(秒)。いちばん小さい T(約 3 秒)の 30 分の 1const ASTERN = 0.35; // 後進で出せる力(前進の 35%) export interface ShipType { key: string; label: string; L: number; B: number; // 全長・幅(m) K0: number; T0: number; kn0: number; // 表の K(1/秒)・T(秒)と、その値を測ったときの速力(ノット) a3: number; // 3 乗の項の係数 α'(長さと速さで割った形) loss: number; // 回ったときに抵抗が増える割合 β kappa: number; // 速さの変わりにくさ。船の長さの κ 倍ほど進むと、速さが新しい値に近づく rate: number; // 舵取機が舵を動かす速さ(°/秒)}// K と T は、広田実『船舶制御システム工学』(成山堂書店)の表の値(東京海洋大学の講義資料に引用されたもの)。// α・β・κ・舵取機の速さは、このシミュレーションで選んだ値export const SHIPS: Record<string, ShipType> = { ferry: { key: "ferry", label: "小型客船", L: 37, B: 7.8, K0: 0.143, T0: 6.2, kn0: 9.7, a3: 0.3, loss: 2.9, kappa: 4, rate: 4 }, cargo: { key: "cargo", label: "貨物船", L: 106, B: 16.2, K0: 0.11, T0: 41.6, kn0: 14, a3: 1.1, loss: 4, kappa: 8, rate: 2.32 }, ballast: { key: "ballast", label: "空荷のタンカー", L: 245, B: 32.9, K0: 0.064, T0: 93.3, kn0: 17.8, a3: 1.3, loss: 5.2, kappa: 10, rate: 2.32 }, loaded: { key: "loaded", label: "満載のタンカー", L: 276, B: 43, K0: 0.099, T0: 201, kn0: 16.5, a3: 3.1, loss: 12, kappa: 15, rate: 2.32 },};/** 長さと速さで割った K' と T' */export const kDash = (t: ShipType) => (t.K0 * t.L) / (t.kn0 * KN);export const tDash = (t: ShipType) => (t.T0 * t.kn0 * KN) / t.L;/** 速さ U(m/秒)のときの K(1/秒)・T(秒)・α(秒²)。止まった船では舵が効かない(K = 0)。T と α は、1 m/秒より遅いときは 1 m/秒の値を使う */export function indices(t: ShipType, U: number, eff = 1) { const u = Math.max(U, 1); return { K: (kDash(t) * eff * Math.max(U, 0)) / t.L, T: (tDash(t) * t.L) / u, A: t.a3 * (t.L / u) ** 2 };}/** 舵角 d を切ったまま回り続けたときの r'(= r·L/U)。r' + α'·r'³ = K'·δ をニュートン法で解く。旋回の半径は L ÷ r' */export function steadyRp(t: ShipType, eff: number, d: number) { const c = kDash(t) * eff * d; let r = c; for (let i = 0; i < 24; i++) r -= (r + t.a3 * r * r * r - c) / (1 + 3 * t.a3 * r * r); return Math.max(r, 1e-3);}
steadyRpは、舵を切ったまま回り続けたときの回頭角速度を、長さと速さで割った形r′で求める。r′+α′r′3=K′δという3次の式を、ニュートン法(接線で答えに近づくやり方)で解いている。道筋の角を丸めるときの半径は、このr′からL/r′で出す。
舵取機と野本の式
stepShipが船を1刻み進める。順番は、舵 → 回頭 → 速さ → 位置。舵角は命じた角度へ1秒に2.32°(小型客船は4°)ずつしか動かない。回頭は、本文のTdr/dt+r=Kδに3乗の項を足した式をdr/dtについて解き、刻みhを掛けて足す(オイラー法)。刻み0.1秒は、いちばん速く反応する船のT(約3秒)の30分の1で、この細かさなら計算がぶれない。
船の運動(src/sims/ship-obstacles.ts の 63〜113 行)/** 船の運動の状態。x は東、y は北(m)。psi は船首方位(北から右回りの rad) */export interface Hull { t: ShipType; x: number; y: number; psi: number; r: number; delta: number; cmd: number; U: number } /** 船を h 秒進める。set は機関で命じる速力(m/秒。負なら全速後進)、eff は舵の効きの倍率、(cx, cy) は潮の流れ(m/秒) */export function stepShip(b: Hull, h: number, set: number, eff: number, cx: number, cy: number) { const t = b.t, { K, T, A } = indices(t, b.U, eff); // 舵取機: 舵角は命じた角度(cmd)へ向かって、1 秒に t.rate 度ずつしか動かない const dm = t.rate * DEG * h; b.delta += clamp(b.cmd - b.delta, -dm, dm); // 野本の式 T·dr/dt + r + α·r³ = K·δ を dr/dt について解き、h 秒ぶん進める b.r += (h * (K * b.delta - b.r - A * b.r * b.r * b.r)) / T; b.psi = (b.psi + h * b.r + TAU) % TAU; // 前に進む速さ: 推す力 − 抵抗。どちらも速さの 2 乗に比例するとして、重さの代わりの κ·L で割る const rp = (b.r * t.L) / Math.max(b.U, 1); const push = set >= 0 ? set * set : -ASTERN * set * set; b.U = Math.max(0, b.U + (h * (push - b.U * b.U * (1 + t.loss * rp * rp))) / (t.kappa * t.L)); // 位置: 水に対する速さ(船首の向き)に潮の流れを足す b.x += h * (b.U * Math.sin(b.psi) + cx); b.y += h * (b.U * Math.cos(b.psi) + cy);} /** 自動操舵: 向けたい船首方位 psiD に向くように舵角を命じる。向きのずれに比例する分から、回頭の速さに比例する分を引く */export function autopilot(b: Hull, psiD: number, eff: number) { const { K, T } = indices(b.t, b.U, eff), k = Math.max(K, 1e-4); // 向きのずれがおよそ 0.4·T の時間の目安で縮むように係数を決める(閉じた系の固有角振動数 2.5/T 以上・減衰 0.85) const kP = Math.max(1, 6.25 / (k * T)), wn = Math.sqrt((k * kP) / T), kD = Math.max(0, (1.7 * wn * T - 1) / k); b.cmd = clamp(kP * wrapAngle(psiD - b.psi) - kD * b.r, -RUDDER_MAX, RUDDER_MAX);} export interface TurnResult { adv: number; tr: number; td: number; t90: number; t180: number; s10: number }const turnMemo = new Map<string, TurnResult>();/** 旋回試験: 速さ U0 でまっすぐ進む船の舵を deg 度切り、向きが 90° 変わった所の縦距・横距と、180° 変わった所の旋回径を測る(潮なし) */export function turnTest(t: ShipType, U0: number, eff: number, deg: number): TurnResult { const key = `${t.key}|${U0.toFixed(2)}|${eff.toFixed(3)}|${deg}`; const m = turnMemo.get(key); if (m) return m; const b: Hull = { t, x: 0, y: 0, psi: 0, r: 0, delta: 0, cmd: deg * DEG, U: Math.max(U0, 0.5) }; const out: TurnResult = { adv: NaN, tr: NaN, td: NaN, t90: NaN, t180: NaN, s10: NaN }; let acc = 0, time = 0, s = 0; const h = 0.25; for (let i = 0; i < 60000 && acc < Math.PI; i++) { stepShip(b, h, b.U > 0 ? Math.max(U0, 0.5) : 0, eff, 0, 0); acc += b.r * h; time += h; s += b.U * h; if (isNaN(out.s10) && acc >= 10 * DEG) out.s10 = s; if (isNaN(out.adv) && acc >= Math.PI / 2) { out.adv = b.y; out.tr = b.x; out.t90 = time; } if (acc >= Math.PI) { out.td = b.x; out.t180 = time; } } if (turnMemo.size > 200) turnMemo.clear(); turnMemo.set(key, out); return out;}
autopilotは自動操舵で、向けたい方位とのずれに比例した舵から、回頭の速さに比例した舵を引く。回っている勢いを先に打ち消しておかないと、目標の向きを行き過ぎてしまうからだ。係数は船のKとTから計算するので、小型客船にもタンカーにも同じ考え方で効く。turnTestは本文の旋回試験と同じ手順で、縦距・横距・旋回径を測る。
扇形に探る
clearanceは、ある点からいちばん近い岩か岸までの距離を返す(中に入っているとマイナス)。探る線は、この距離のぶんだけ一気に進んでは測り直す。距離の内側には岩が無いと分かっているので、細かく刻まずに済む。
扇で探る(src/sims/ship-obstacles.ts の 148〜222 行)/** 岩の縁までの半径(角度 a は東から左回り)。円を少しゆがめた形 */const rockR = (k: Rock, a: number) => k.r * (1 + k.a1 * Math.sin(3 * a + k.p1) + k.a2 * Math.sin(5 * a + k.p2)); /** 陸(多角形)までの距離。中ではマイナス。best より遠いと分かれば計算を省く */function landDist(ld: Land, x: number, y: number, best: number) { const ox = Math.max(ld.x0 - x, 0, x - ld.x1), oy = Math.max(ld.y0 - y, 0, y - ld.y1); if (Math.hypot(ox, oy) > best) return Infinity; const p = ld.p, n = ld.n; let d2 = Infinity, inside = false; for (let i = 0, j = n - 1; i < n; j = i++) { const xi = p[2 * i], yi = p[2 * i + 1], xj = p[2 * j], yj = p[2 * j + 1]; const ex = xi - xj, ey = yi - yj, wx = x - xj, wy = y - yj; const u = clamp((wx * ex + wy * ey) / (ex * ex + ey * ey || 1), 0, 1); const dx = wx - ex * u, dy = wy - ey * u; d2 = Math.min(d2, dx * dx + dy * dy); if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; } const d = Math.sqrt(d2); return inside ? -d : d;}/** 点 (x, y) から、いちばん近い岩か陸までの距離(m)。岩や陸の中ではマイナス */export function clearance(w: World, x: number, y: number) { let d = 1e9; for (const k of w.rocks) { const dx = x - k.x, dy = y - k.y, h = Math.hypot(dx, dy); if (h - k.r * 1.15 >= d) continue; // いちばん出っ張った所より遠い岩は調べない const v = h - rockR(k, Math.atan2(dy, dx)); if (v < d) d = v; } for (const ld of w.land) { const v = landDist(ld, x, y, d); if (v < d) d = v; } return d;}/** 探る線を 1 本伸ばす: (x, y) から向き (dx, dy) へ、岩まで thr より近づく所までの長さ。いちばん近い岩までの距離ずつ進む(球をなぞる方法) */function march(w: World, x: number, y: number, dx: number, dy: number, R: number, thr: number) { let t = 0; for (let i = 0; i < 90 && t < R; i++) { const d = clearance(w, x + dx * t, y + dy * t); if (d < thr) return t; t += Math.max((d - thr) * 0.85, 2); } return Math.min(t, R);}/** 進みたい向き chi(陸から見た向き)に進むための船首方位。潮を見込む船は、横から流される分だけ船首を潮上に向ける */function headingFor(chi: number, U: number, cx: number, cy: number, aware: boolean) { if (!aware || U < 0.3) return chi; const side = cx * Math.cos(chi) - cy * Math.sin(chi); // 進む向きに対して右向きの潮の速さ return chi + Math.asin(clamp(-side / U, -0.95, 0.95));}/** 扇形に探る: 船首から扇形に線を伸ばし、ふさがっていない線のうち目的地の向きにいちばん近いものを選ぶ */function sense(api: Api, s: S, sh: Ship) { const w = s.world, n = sh.nRay, half = 70 * DEG, L = sh.t.L; const [cx, cy] = s.curV; const gx = sh.U * Math.sin(sh.psi) + cx, gy = sh.U * Math.cos(sh.psi) + cy; // 扇の真ん中: 潮を見込む船は「陸から見て進んでいる向き」、見込まない船は船首の向き const base = sh.aware && Math.hypot(gx, gy) > 0.5 ? Math.atan2(gx, gy) : sh.psi; const bx = sh.x + (Math.sin(sh.psi) * L) / 2, by = sh.y + (Math.cos(sh.psi) * L) / 2; const safe = api.num("safety") + sh.t.B / 2; // すでに安全距離の内側にいるときは「今より近づかない」を条件にする const thr = Math.min(safe, Math.max(sh.t.B / 2, clearance(w, bx, by) - 3)); const [tx, ty] = sh.ends[sh.leg]; const toT = Math.atan2(tx - bx, ty - by), dT = Math.hypot(tx - bx, ty - by); const R = sh.range, need = Math.min(R, Math.max(dT, L)); let best = 0, bc = Infinity, far = 0; for (let i = 0; i < n; i++) { const a = base - half + (2 * half * i) / (n - 1); const d = march(w, bx, by, Math.sin(a), Math.cos(a), R, thr); sh.rayA[i] = a; sh.rayD[i] = d; far = Math.max(far, d); // 点数: 目的地の向きからのずれ + ふさがった線への罰 + 前の判断からの変わり方(ふらつかないように) const cost = Math.abs(wrapAngle(a - toT)) + (d < need ? 2.5 + 2 * (1 - d / need) : 0) + 0.25 * Math.abs(wrapAngle(a - sh.chiD)); if (cost < bc) { bc = cost; best = i; } } sh.pick = best; sh.chiD = sh.rayA[best]; sh.far = far; sh.base = base; // 目的地へまっすぐの向きが空いていれば、その向きを選ぶ if (Math.abs(wrapAngle(toT - base)) < half && march(w, bx, by, Math.sin(toT), Math.cos(toT), need, thr) >= need) { sh.chiD = toT; sh.pick = -1; }}
senseは、扇の線ごとに点数をつけ、いちばん低い線を選ぶ。点数は「目的地の向きからのずれ」「ふさがっていれば大きな罰」「前の判断からの変わり方」の3つの和である。最後の項は、毎回ちがう線を選んでふらつくのを防ぐためにある。headingForは本文のsinγ=C⊥/Uで、潮を見込む船の船首を潮上へ向ける。
道筋を引く
海を格子に分け、岩から安全距離以上離れた格子だけを通って目的地までいちばん安く行ける道を、A* 法で探す。岩に近い格子ほど高くつくので、道は水路の中ほどを通りやすい。
道筋を引く(src/sims/ship-obstacles.ts の 224〜382 行)/** 海を格子に分け、格子ごとに岩までの距離を入れておく(岩が動いたときだけ作り直す) */function gridOf(api: Api, s: S): Grid { const q = api.quality, ver = s.world.ver; if (s.grid && s.grid.ver === ver && s.grid.q === q) return s.grid; const b = s.box, m = 0.2 * Math.max(b.A, b.C), W = b.A + 2 * m, Hh = b.C + 2 * m; const cs = Math.max(W, Hh) / [60, 90, 120][q]; const nx = Math.ceil(W / cs), ny = Math.ceil(Hh / cs), N = nx * ny; const g: Grid = s.grid && s.grid.nx === nx && s.grid.ny === ny ? s.grid : { nx, ny, cs, x0: 0, y0: 0, sd: new Float32Array(N), ver, q, gs: new Float32Array(N), par: new Int32Array(N), cl: new Uint8Array(N), hp: new Int32Array(N * 8), hf: new Float32Array(N * 8) }; g.cs = cs; g.x0 = b.cx - W / 2; g.y0 = b.cy - Hh / 2; g.ver = ver; g.q = q; for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) g.sd[j * nx + i] = clearance(s.world, g.x0 + (i + 0.5) * cs, g.y0 + (j + 0.5) * cs); s.grid = g; return g;}const cellOf = (g: Grid, x: number, y: number) => clamp(Math.floor((y - g.y0) / g.cs), 0, g.ny - 1) * g.nx + clamp(Math.floor((x - g.x0) / g.cs), 0, g.nx - 1);/** いちばん近い、通れる格子(岩から infl 以上離れた格子) */function nearestFree(g: Grid, c: number, infl: number) { const cx = c % g.nx, cy = (c / g.nx) | 0; for (let rad = 0; rad <= 10; rad++) { let best = -1, bd = Infinity; for (let j = cy - rad; j <= cy + rad; j++) for (let i = cx - rad; i <= cx + rad; i++) { if (i < 0 || j < 0 || i >= g.nx || j >= g.ny || (Math.abs(i - cx) !== rad && Math.abs(j - cy) !== rad)) continue; const k = j * g.nx + i, d = Math.hypot(i - cx, j - cy); if (g.sd[k] >= infl && d < bd) { bd = d; best = k; } } if (best >= 0) return best; } return -1;}/** A* 法: 格子の上で、出発の格子から目的の格子までのいちばん安い道を探す。岩に近い格子ほど高くつく */function astar(g: Grid, si: number, gi: number, infl: number, pref: number): number[] | null { const { nx, ny, sd, gs, par, cl, hp, hf } = g; gs.fill(Infinity); par.fill(-1); cl.fill(0); let hn = 0; const gx = gi % nx, gy = (gi / nx) | 0; const heur = (i: number) => { const dx = Math.abs((i % nx) - gx), dy = Math.abs(((i / nx) | 0) - gy); return dx + dy + (Math.SQRT2 - 2) * Math.min(dx, dy); }; const push = (i: number, f: number) => { if (hn >= hp.length) return; let c = hn++; while (c > 0) { const p = (c - 1) >> 1; if (hf[p] <= f) break; hp[c] = hp[p]; hf[c] = hf[p]; c = p; } hp[c] = i; hf[c] = f; }; const pop = () => { const top = hp[0], li = hp[--hn], lf = hf[hn]; let c = 0; for (;;) { let l = 2 * c + 1; if (l >= hn) break; if (l + 1 < hn && hf[l + 1] < hf[l]) l++; if (hf[l] >= lf) break; hp[c] = hp[l]; hf[c] = hf[l]; c = l; } hp[c] = li; hf[c] = lf; return top; }; const free = (i: number) => sd[i] >= infl; gs[si] = 0; push(si, heur(si)); while (hn > 0) { const i = pop(); if (cl[i]) continue; cl[i] = 1; if (i === gi) break; const ix = i % nx, iy = (i / nx) | 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const jx = ix + dx, jy = iy + dy; if (jx < 0 || jy < 0 || jx >= nx || jy >= ny) continue; const j = jy * nx + jx; if (cl[j] || !free(j)) continue; if (dx && dy && (!free(iy * nx + jx) || !free(jy * nx + ix))) continue; // 岩の角をかすめて斜めに抜けない const near = Math.max(0, 1 - (sd[j] - infl) / pref); // 岩から pref 以上離れれば 0 const ng = gs[i] + (dx && dy ? Math.SQRT2 : 1) * (1 + 3 * near * near); if (ng < gs[j]) { gs[j] = ng; par[j] = i; push(j, ng + heur(j)); } } } if (gi !== si && par[gi] < 0) return null; const out: number[] = []; for (let i = gi; i >= 0 && out.length < nx * ny; i = par[i]) { out.push(i); if (i === si) break; } return out.reverse();}/** 線分 a → b が、岩から thr 以上離れているか。調べる点の間隔は thr の 0.4 倍(4〜40 m)。点と点の間で岩に近づける量は間隔の半分まで */function segClear(w: World, ax: number, ay: number, bx: number, by: number, thr: number) { const l = Math.hypot(bx - ax, by - ay), n = Math.max(2, Math.ceil(l / clamp(thr * 0.4, 4, 40))); for (let i = 0; i <= n; i++) if (clearance(w, ax + ((bx - ax) * i) / n, ay + ((by - ay) * i) / n) < thr) return false; return true;}/** 折れ線の角を、半径 R0 の円弧で丸める(角の前後が短ければ半径を小さくする) */function fillet(Q: number[], R0: number, sh: Ship) { const out = sh.path, cap = out.length / 2; let n = 0; const put = (x: number, y: number) => { if (n < cap) { out[2 * n] = x; out[2 * n + 1] = y; n++; } }; const m = Q.length / 2; put(Q[0], Q[1]); for (let i = 1; i < m - 1; i++) { const ax = Q[2 * i - 2], ay = Q[2 * i - 1], bx = Q[2 * i], by = Q[2 * i + 1], cx = Q[2 * i + 2], cy = Q[2 * i + 3]; const l1 = Math.hypot(bx - ax, by - ay), l2 = Math.hypot(cx - bx, cy - by); if (l1 < 1e-6 || l2 < 1e-6) continue; const u1x = (bx - ax) / l1, u1y = (by - ay) / l1, u2x = (cx - bx) / l2, u2y = (cy - by) / l2; const phi = Math.acos(clamp(u1x * u2x + u1y * u2y, -1, 1)); if (phi < 3 * DEG) { put(bx, by); continue; } let tl = R0 * Math.tan(phi / 2), R = R0; const lim = Math.min(l1, l2) * 0.5; if (tl > lim) { tl = lim; R = lim / Math.tan(phi / 2); } const sx = bx - u1x * tl, sy = by - u1y * tl; const left = u1x * u2y - u1y * u2x > 0; // 左回りに曲がる角 const ox = sx + (left ? -u1y : u1y) * R, oy = sy + (left ? u1x : -u1x) * R; // 円弧の中心 const a0 = Math.atan2(sy - oy, sx - ox), k = Math.max(2, Math.ceil(phi / (6 * DEG))); for (let j = 0; j <= k; j++) { const a = a0 + ((left ? 1 : -1) * phi * j) / k; put(ox + R * Math.cos(a), oy + R * Math.sin(a)); } } put(Q[2 * m - 2], Q[2 * m - 1]); sh.pathN = n;}/** 道筋を引く: 格子の上の最短の道を探し、まっすぐに結べる所は結び、角を船が回れる丸みにする */function plan(api: Api, s: S, sh: Ship) { const g = gridOf(api, s), w = s.world, L = sh.t.L, eff = api.num("eff"); const infl = api.num("safety") + sh.t.B / 2; const [tx, ty] = sh.ends[sh.leg]; sh.planVer = w.ver; sh.planT = 0; sh.planTx = tx; sh.planTy = ty; sh.pathI = 0; sh.lastPlan = api.t; // 舵が効き始めるまでに進む距離(およそ 0.6·U·T)は、船首の向きにまっすぐ進むものとして道筋を始める const { T } = indices(sh.t, Math.max(sh.U, 1), eff); const hx = Math.sin(sh.psi), hy = Math.cos(sh.psi); const near = Math.min(infl, Math.max(sh.t.B / 2, clearance(w, sh.x, sh.y) - 2)); let lead = clamp(0.6 * sh.U * T, 0.5 * L, 6 * L); for (let k = 0; k < 6 && lead > 0.3 * L && !segClear(w, sh.x, sh.y, sh.x + hx * lead, sh.y + hy * lead, near); k++) lead *= 0.6; const sx = sh.x + hx * lead, sy = sh.y + hy * lead; const si = nearestFree(g, cellOf(g, sx, sy), infl), gi = nearestFree(g, cellOf(g, tx, ty), infl); const cells = si >= 0 && gi >= 0 ? astar(g, si, gi, infl, Math.max(2 * L, 2 * infl)) : null; if (!cells) { sh.noPath = true; sh.pathN = 0; return; } sh.noPath = false; // 道筋の点: 船 → まっすぐ進む先 → 格子の中心 … → 目的地 const P: number[] = [sh.x, sh.y, sx, sy]; for (const c of cells) P.push(g.x0 + ((c % g.nx) + 0.5) * g.cs, g.y0 + (((c / g.nx) | 0) + 0.5) * g.cs); P.push(tx, ty); // 間の点を間引く: なるべく遠くの点とまっすぐ結ぶ。ただし、もとの道が保っていた岩からの距離(広い所では infl + 船の長さの 0.6 倍まで)は割り込まない const m = P.length / 2, Q: number[] = [P[0], P[1], P[2], P[3]], margin = Math.max(0.6 * L, 2 * g.cs); const cl = new Float32Array(m); for (let i = 0; i < m; i++) cl[i] = clearance(w, P[2 * i], P[2 * i + 1]); let a = 1, low = cl[1]; for (let j = a + 2; j < m; j++) { low = Math.min(low, cl[j - 1], cl[j]); if (!segClear(w, P[2 * a], P[2 * a + 1], P[2 * j], P[2 * j + 1], Math.max(infl * 0.9, Math.min(infl + margin, low) * 0.95))) { Q.push(P[2 * (j - 1)], P[2 * (j - 1) + 1]); a = j - 1; low = cl[a]; } } Q.push(P[2 * (m - 1)], P[2 * (m - 1) + 1]); // 角の丸み: 舵角 20° で回り続けたときの円の半径 fillet(Q, L / steadyRp(sh.t, eff, 20 * DEG), sh);}/** 道筋を追う(視線誘導): 道筋の上で船にいちばん近い点から、さらに delta 先の点へ向かう */function follow(sh: Ship, x: number, y: number, st: { i: number }, delta: number) { const P = sh.path, n = sh.pathN; let bi = st.i, bd = Infinity, bt = 0; for (let i = st.i; i < Math.min(n - 1, st.i + 40); i++) { const ax = P[2 * i], ay = P[2 * i + 1], ex = P[2 * i + 2] - ax, ey = P[2 * i + 3] - ay; const u = clamp(((x - ax) * ex + (y - ay) * ey) / (ex * ex + ey * ey || 1), 0, 1), d = Math.hypot(ax + ex * u - x, ay + ey * u - y); if (d < bd) { bd = d; bi = i; bt = u; } } st.i = bi; let px = P[2 * bi] + (P[2 * bi + 2] - P[2 * bi]) * bt, py = P[2 * bi + 1] + (P[2 * bi + 3] - P[2 * bi + 1]) * bt, rem = delta, i = bi; while (i < n - 1 && rem > 0) { const qx = P[2 * i + 2], qy = P[2 * i + 3], l = Math.hypot(qx - px, qy - py); if (l >= rem) { px += ((qx - px) * rem) / l; py += ((qy - py) * rem) / l; rem = 0; break; } rem -= l; px = qx; py = qy; i++; } return { chi: Math.atan2(px - x, py - y), xt: bd };}const lookahead = (sh: Ship) => Math.max(1.2 * sh.t.L, 60);
見つかった道は格子の角をなぞった折れ線なので、まっすぐ結べる所は結び、残った角を舵角20°で回れる半径の円弧で丸める。道筋の最初には、舵が効き始めるまでにまっすぐ進む区間(およそ0.6UT)を置いている。followは道筋の上で船にいちばん近い点から、船の長さの1.2倍先の点へ向かわせる(視線誘導と呼ばれるやり方)。
先読みの軌跡
船の写しを作り、今の判断と同じ自動操舵で先へ進めて、実線の軌跡を描く。式は本物の船と同じなので、舵の遅れも潮で流される分も入っている。刻みはTの6分の1と粗くして、1コマの計算を軽くしている。
先読み(src/sims/ship-obstacles.ts の 384〜417 行)/** 船の写しを作り、今の判断のまま進めたときの軌跡を先に計算する(舵の遅れも潮の流れも本物と同じ式で) */function predict(api: Api, s: S, sh: Ship) { const w = s.world, eff = api.num("eff"), [cx, cy] = s.curV, mode = sh.mode ?? (api.str("mode") as Mode); const b: Hull = { t: sh.t, x: sh.x, y: sh.y, psi: sh.psi, r: sh.r, delta: sh.delta, cmd: sh.cmd, U: sh.U }; const { T } = indices(sh.t, Math.max(sh.U, 1), eff); const h = clamp(T / 6, 0.5, 4), maxD = Math.max(sh.range * 1.4, 5 * sh.t.L), set = setOf(api, sh); const [tx, ty] = sh.ends[sh.leg], st = { i: sh.pathI }, P = sh.pred, test = s.test && sh === s.ships[0]; let n = 0, dist = 0; sh.predHit = false; P[n++] = b.x; P[n++] = b.y; for (let k = 0; k < 300 && dist < maxD && sh.state === "run"; k++) { if (!test) { let chi = sh.chiD; if (mode === "plan" && sh.pathN > 1) chi = follow(sh, b.x, b.y, st, lookahead(sh)).chi; else if (mode === "none") chi = Math.atan2(tx - b.x, ty - b.y); autopilot(b, headingFor(chi, b.U, cx, cy, sh.aware), eff); } const ox = b.x, oy = b.y; stepShip(b, h, set, eff, cx, cy); dist += Math.hypot(b.x - ox, b.y - oy); P[n++] = b.x; P[n++] = b.y; if (hullHit(w, b)) { sh.predHit = true; break; } if (!test && Math.hypot(tx - b.x, ty - b.y) < arriveR(sh)) break; if (b.U < 0.05 && set <= 0) break; } sh.predN = n / 2; sh.predH = h;}/** 船体の 6 か所(船首・船尾・両舷)のどこかが岩か陸に入っていれば座礁 */function hullHit(w: World, b: Hull) { const sx = Math.sin(b.psi), sy = Math.cos(b.psi), hl = b.t.L / 2, hb = b.t.B / 2; const pts = [[1, 0], [-1, 0], [0, 1], [0, -1], [0.55, 0.75], [0.55, -0.75]]; for (const [a, c] of pts) if (clearance(w, b.x + sx * a * hl + sy * c * hb, b.y + sy * a * hl - sx * c * hb) < 0) return true; return false;}
船体の6か所のどこかが岩か岸に入ったら座礁とし、写しのほうで先に入れば、画面に「このままだと座礁」と出す。
1コマの計算
tickは1刻みぶん、すべての船について「判断 → 舵を命じる → 運動を進める → 座礁と到着を調べる」をくり返す。旋回試験の場面だけは判断の代わりに、まっすぐ進んでから舵を右35°に固定し、向きが90°と180°変わった所で縦距・横距・旋回径を記録する。
1コマの計算(src/sims/ship-obstacles.ts の 419〜496 行)const arriveR = (sh: Ship) => Math.max(1.5 * sh.t.L, 60);/** 機関で命じる速力(m/秒)。全速後進は負、止めたときは 0 */function setOf(api: Api, sh: Ship) { const v = api.num("speed") * KN; return sh.stop || sh.state !== "run" ? 0 : sh.astern ? -v : v;}function note(api: Api, s: S, msg: string) { const now = api.t; if ((s.noteT[msg] ?? -99) > now - 4) return; s.noteT[msg] = now; api.note(msg, 2800);}/** 船ごとの判断: よけ方に従って進みたい向きを決め、自動操舵に渡す */function guide(api: Api, s: S, sh: Ship, h: number) { const mode = sh.mode ?? (api.str("mode") as Mode), eff = api.num("eff"), [cx, cy] = s.curV, w = s.world; const [tx, ty] = sh.ends[sh.leg]; // 止まっていた船は、岩か目的地が変わればまた進む if (sh.stop && (sh.stopVer !== w.ver || Math.hypot(tx - sh.stopTx, ty - sh.stopTy) > 1)) { sh.stop = false; sh.senseT = 0; } if (mode === "sense") { sh.senseT -= h; if (sh.senseT <= 0) { sense(api, s, sh); sh.senseT = clamp(indices(sh.t, Math.max(sh.U, 1), eff).T / 15, 0.3, 3); } // 扇の線がすべて近くでふさがったら、全速後進をかける if (!sh.astern && !sh.stop && sh.far < 0.35 * sh.range && sh.U > 0.5) { sh.astern = true; note(api, s, `${sh.name}は前がふさがり、全速後進をかけました`); } else if (sh.astern && sh.far > 0.55 * sh.range) sh.astern = false; } else if (mode === "plan") { sh.astern = false; sh.planT += h; const moved = Math.hypot(tx - sh.planTx, ty - sh.planTy) > 1; const xt = sh.pathN > 1 ? follow(sh, sh.x, sh.y, { i: sh.pathI }, lookahead(sh)).xt : Infinity; const stale = sh.planVer < 0 || (sh.planVer !== w.ver && api.t - sh.lastPlan > 0.2); const off = xt > Math.max(1.5 * sh.t.L, 3 * (s.grid?.cs ?? 20)) + 0.5 * api.num("safety"); if (stale || moved || (off && sh.planT > 5)) plan(api, s, sh); if (sh.noPath) { if (!sh.stop) { sh.stop = true; sh.stopVer = w.ver; sh.stopTx = tx; sh.stopTy = ty; note(api, s, "通れる道筋が見つからないので、機関を止めました"); } } else if (sh.pathN > 1) { const st = { i: sh.pathI }; sh.chiD = follow(sh, sh.x, sh.y, st, lookahead(sh)).chi; sh.pathI = st.i; } } else { sh.chiD = Math.atan2(tx - sh.x, ty - sh.y); sh.astern = false; } sh.psiD = headingFor(sh.chiD, sh.U, cx, cy, sh.aware); autopilot(sh, sh.psiD, eff); // 後進で止まりきったら、そこで待つ if (sh.astern && sh.U < 0.2) { sh.astern = false; sh.stop = true; sh.stopVer = w.ver; sh.stopTx = tx; sh.stopTy = ty; note(api, s, `${sh.name}は止まりました。岩を動かすか、目的地を置き直すと進みます`); }}/** 旋回試験: まっすぐ進んでから舵を右 35° に切り、向きが 90°・180° 変わった所で縦距・横距・旋回径を測る */function testStep(api: Api, s: S, sh: Ship, h: number) { const T = s.test!; if (T.phase === 0) { autopilot(sh, T.psi0, api.num("eff")); if (Math.hypot(sh.x - T.sx, sh.y - T.sy) >= 1.2 * sh.t.L) { T.phase = 1; T.x0 = sh.x; T.y0 = sh.y; T.acc = 0; T.time = 0; T.pts = [sh.x, sh.y]; } return; } sh.cmd = RUDDER_MAX; if (T.phase !== 1) return; T.acc += sh.r * h; T.time += h; const n = T.pts.length; if (Math.hypot(sh.x - T.pts[n - 2], sh.y - T.pts[n - 1]) > sh.t.L / 12) T.pts.push(sh.x, sh.y); const ex = Math.sin(T.psi0), ey = Math.cos(T.psi0), dx = sh.x - T.x0, dy = sh.y - T.y0; if (!T.p90 && T.acc >= Math.PI / 2) { T.adv = dx * ex + dy * ey; T.tr = dx * ey - dy * ex; T.t90 = T.time; T.p90 = [sh.x, sh.y]; } if (!T.p180 && T.acc >= Math.PI) { T.td = dx * ey - dy * ex; T.t180 = T.time; T.p180 = [sh.x, sh.y]; } if (T.acc >= 2.2 * Math.PI) { T.phase = 2; T.wait = 3; }}/** h 秒ぶん、すべての船を進める */function tick(api: Api, s: S, h: number) { s.time += h; const eff = api.num("eff"), [cx, cy] = s.curV; for (const sh of s.ships) { if (sh.state === "ground") continue; if (sh.state === "run") { if (s.test && sh === s.ships[0]) testStep(api, s, sh, h); else guide(api, s, sh, h); } else sh.cmd = 0; stepShip(sh, h, setOf(api, sh), eff, cx, cy); pushTrail(s, sh, h); pushHist(sh, h); if (sh.state !== "run") continue; if (hullHit(s.world, sh)) { sh.state = "ground"; sh.U = 0; sh.r = 0; sh.wait = 2.6; s.marks.push(sh.x, sh.y); if (s.marks.length > 16) s.marks.splice(0, 2); note(api, s, `${sh.name}が乗り上げました(座礁)`); api.emit("ground", { ship: sh.t.key }); } else if (!s.test && Math.hypot(sh.ends[sh.leg][0] - sh.x, sh.ends[sh.leg][1] - sh.y) < arriveR(sh)) { if (s.loop) sh.state = "done"; else { sh.leg ^= 1; sh.senseT = 0; } } }}
引き波
船首の位置・向き・速さを0.25秒ごとに記録しておき、そこから波の山の位置を計算する。n番目の山のうち、進む向きから角θの向きへ広がる部分は、τ=4πnUcosθ/g秒前に船首から出た波で、そこからnλcos2θだけ進んだ所にある。潮が流れていれば、波も潮といっしょにCτだけ流される。
引き波(src/sims/ship-obstacles.ts の 498〜580 行)/** 航跡を 0.25 秒ごとに記録する(船首の位置・向き・速さ・時刻)。引き波の山は、この記録から計算する */function pushTrail(s: S, sh: Ship, h: number) { sh.trT += h; if (sh.trT < DT_TR) return; sh.trT -= DT_TR; const i = sh.trHead * 6, sx = Math.sin(sh.psi), sy = Math.cos(sh.psi), T = sh.tr; T[i] = sh.x + (sx * sh.t.L) / 2; T[i + 1] = sh.y + (sy * sh.t.L) / 2; T[i + 2] = sx; T[i + 3] = sy; T[i + 4] = sh.U; T[i + 5] = s.time; sh.trHead = (sh.trHead + 1) % TRN; sh.trN = Math.min(TRN, sh.trN + 1); sh.trCount++;}/** tau 秒前の船首の位置・向き・速さ(記録の間は直線で補う)。記録が足りなければ false */function trailAt(s: S, sh: Ship, tau: number, e: Float64Array) { if (sh.trN < 2) return false; const T = sh.tr, newest = (sh.trHead - 1 + TRN) % TRN; const f = Math.max(0, (tau - (s.time - T[newest * 6 + 5])) / DT_TR), j = Math.floor(f), u = f - j; if (j + 1 >= sh.trN) return false; const a = ((newest - j + TRN) % TRN) * 6, b = ((newest - j - 1 + TRN) % TRN) * 6; for (let k = 0; k < 5; k++) e[k] = T[a + k] + (T[b + k] - T[a + k]) * u; return true;}/** * ケルビンの引き波: 深い海を速さ U で進む船が τ 秒前に出した波のうち、進む向きから角 θ の向きへ広がる波は、 * 群速度(位相の速さ U·cosθ の半分)で τ 秒進んだ所にある。n 番目の山は τ = 4πn·U·cosθ/g の波でできる。 * θ を動かして点をつなぐと、真後ろの横波から 19.47° の線の上の折り返しを経て、船首から斜めに出る波まで 1 本の山になる。 * 船首のすぐ近くでは山がすべて 1 点に集まって白くつぶれるので、θ は ±74° までにした */function drawWake(api: Api, s: S, sh: Ship) { const c = s.cam!, ctx = api.ctx, [cx, cy] = s.curV, e = s.tmp; if (sh.trN < 8) return; const Uref = sh.tr[((sh.trHead - 1 + TRN) % TRN) * 6 + 4]; if (Uref < 1) return; const lam = (TAU * Uref * Uref) / GRAV; // 横波の波長(山と山の間隔)λ = 2πU²/g const every = Math.max(1, Math.ceil(api.u(8) / (lam * c.k))); // 画面の上で間隔が狭すぎる山は間引く // 描く長さ: 画面の短い辺の 4 割まで(古い波ほど低く広がるので、遠くの山は描かない) const nMax = Math.min(32 * every, Math.floor((0.4 * Math.min(api.w, api.h)) / (lam * c.k))); const NJ = [24, 34, 46][api.quality], maxAge = (sh.trN - 2) * DT_TR; ctx.strokeStyle = api.ink; ctx.setLineDash([]); for (let n = every; n <= nMax; n += every) { ctx.beginPath(); let pen = false, any = false, young = false, lx = 0, ly = 0; const jump = Math.max(api.u(14), 0.45 * n * lam * c.k); for (let j = 0; j <= NJ; j++) { const th = (-74 + (148 * j) / NJ) * DEG, ct = Math.cos(th), st = Math.sin(th); let Ue = Uref, tau = 0, ok = true; for (let it = 0; it < 3; it++) { tau = (2 * TAU * n * Ue * ct) / GRAV; if (tau > maxAge || !trailAt(s, sh, tau, e)) { ok = false; break; } Ue = e[4]; } if (ok) young = true; if (!ok || Ue < 0.8) { pen = false; continue; } const rho = ((TAU * Ue * Ue) / GRAV) * n * ct * ct; // 波が τ 秒で進んだ距離 = n·λ·cos²θ const ux = e[2] * ct - e[3] * st, uy = e[2] * st + e[3] * ct; const [X, Y] = toS(c, e[0] + cx * tau + rho * ux, e[1] + cy * tau + rho * uy); // 曲がっている船では、となりの点が遠くへ飛ぶことがある(違う時刻に出た波)。そこは線を切る if (pen && Math.hypot(X - lx, Y - ly) > jump) pen = false; if (pen) ctx.lineTo(X, Y); else { ctx.moveTo(X, Y); pen = true; } lx = X; ly = Y; any = true; } if (!young) break; // これより古い山は記録が無い if (!any) continue; ctx.lineWidth = api.u(0.9) * (1 - 0.7 * (n / nMax)) * (sh.ghost ? 0.8 : 1); ctx.stroke(); }}/** 船尾のすぐ後ろの泡(推進器がかき回した水)を、細かい点で描く */function drawWash(api: Api, s: S, sh: Ship) { const c = s.cam!, ctx = api.ctx, T = sh.tr, L = sh.t.L, [cx, cy] = s.curV; const len = Math.min(sh.trN - 1, Math.round((3 * L) / Math.max(sh.U, 0.8) / DT_TR)), skip = Math.max(1, Math.round(len / 90)); const sz = Math.max(api.u(0.9), 1); ctx.fillStyle = api.ink; ctx.beginPath(); for (let j = 1; j < len; j += skip) { const i = ((sh.trHead - 1 - j + TRN * 2) % TRN) * 6, id = sh.trCount - j, age = s.time - T[i + 5]; const spread = (sh.t.B / 2) * (0.5 + (2.2 * j) / len); for (let q = 0; q < 1; q++) { const hsh = Math.sin(id * 12.9898 + q * 78.233) * 43758.5453, o = (hsh - Math.floor(hsh)) * 2 - 1; const wx = T[i] - T[i + 2] * L * (1 + 0.04 * q) + T[i + 3] * o * spread + cx * age, wy = T[i + 1] - T[i + 3] * L * (1 + 0.04 * q) - T[i + 2] * o * spread + cy * age; const [X, Y] = toS(c, wx, wy); ctx.rect(X, Y, sz, sz); } } ctx.fill();}
θを少しずつ変えて点をつなぐと、1本の山が描ける。曲がっている船では、となりのθの点が別の時刻の波になって遠くへ飛ぶことがあるので、そこで線を切っている。
小さな図の計算
本文の小さな図は、同じstepShipを使い、舵を時刻0に切ってから向きが90°変わるまで(長くてもTの3倍まで)を241個の時刻で記録している。
時間の流れ(src/sims/ship-obstacles-delay.ts の 9〜31 行)/** 舵を時刻 0 に切り、N 個の時刻で舵角・回頭角速度・向きの変化・位置を記録する(潮なし・機関はそのまま) */function compute(api: Api, s: S) { const t = SHIPS[api.str("ship")] ?? SHIPS.cargo, U = api.num("speed") * KN, dmax = api.num("rudder") * DEG; const { T } = indices(t, U); // 見る時間: 舵を切り終えるまでの時間 + T の 3 倍(r がほぼ落ち着く)。ただし向きが 90° 変わるまで let tEnd = Math.max(dmax / (t.rate * DEG) + 3 * T, 20); { const b: Hull = { t, x: 0, y: 0, psi: 0, r: 0, delta: 0, cmd: dmax, U }; let a = 0, tt = 0; while (tt < tEnd && a < Math.PI / 2) { stepShip(b, 0.1, U, 1, 0, 0); a += b.r * 0.1; tt += 0.1; } tEnd = Math.max(tt, 10); } const sub = 8, h = tEnd / (N - 1) / sub; const b: Hull = { t, x: 0, y: 0, psi: 0, r: 0, delta: 0, cmd: dmax, U }; let acc = 0; s.i10 = -1; for (let i = 0; i < N; i++) { if (i) for (let k = 0; k < sub; k++) { stepShip(b, h, U, 1, 0, 0); acc += b.r * h; } s.t[i] = i * h * sub; s.d[i] = b.delta; s.r[i] = b.r; s.psi[i] = acc; s.x[i] = b.x; s.y[i] = b.y; if (s.i10 < 0 && acc >= 10 * DEG) s.i10 = i; } s.tEnd = tEnd; s.T = T; s.L = t.L;}