舵を切っても、大きな船はすぐには曲がらない

長さ276 mの満載のタンカーでは、曲がり始めの遅れを表す時間Tが約200秒ある。岩をよけるには、その遅れの間に進む距離まで先に読まなければならない。

記事

舵を切っても、大きな船はすぐには曲がらない

長さ276 mの満載のタンカーでは、曲がり始めの遅れを表す時間Tが約200秒ある。岩をよけるには、その遅れの間に進む距離まで先に読まなければならない。

作成日 2026年11月3日・更新日 2026年11月3日・執筆・監修 佐々木 航

現象

岩に気づいてから舵を切っても

車なら、ハンドルを切ったとたんに向きが変わり始める。船はそうはいかない。舵を右いっぱいに切っても、大きな船の船首はしばらく元の向きのまま進み、それからゆっくりと回り始める。

遅れの長さは、船によって大きく違う。広田実の教科書にある表では、曲がり始めの遅れの目安になる時間Tが、長さ37 mの小型の客船で6.2秒、長さ276 mの満載のタンカーでは201秒もある。3分あまり。そのあいだに、16.5ノットで走るタンカーは約1.7 km進んでしまう。

では、前方の岩に気づいた船は、どれだけ手前で舵を切ればよいのか。答えは船の長さと速さで決まり、潮が流れていればさらに変わる。題の下の海に岩を置くと、船が自分でよけようとする様子から、その違いを見比べられる。

仕組み

舵は船尾を押し、船体が向きを変える

舵は船尾にある1枚の板である。右に切ると、舵に当たる水の流れが右へ曲げられ、その反動で船尾が左へ押される。船尾が左へずれれば、船首は右を向く。

向きが変わり始めると、船体は水の流れを少し斜めに受けるようになり、船体そのものが大きな翼のように横向きの力を受ける。円の中心へ向かって船を回し続けるのは、主にこの船体の力だ。舵の力はきっかけにすぎない。ただ、数万トンの船体と、いっしょに動く周りの水を回し始めるには時間がかかる。これが遅れの正体である。

野本謙作らは1950年代に、この動きを1本の式で表した。言葉で言えば「回頭角速度rは、舵角δに比例する値Kδへ、時間Tをかけて近づく」となる。

rは1秒あたりに船首の向きが変わる角度(回頭角速度、rad/秒)、δは舵角(rad)である。Kは旋回力指数と呼ばれ、舵をどれだけ切ると最後にどれだけ速く回るかを表す(単位は1/秒)。Tは追従安定性指数で、単位は秒。舵を一度に切ったとき、回頭角速度は次のように増えていき、T秒たった所で最後の値の約63%になる。

舵を一度に切ったときの回頭角速度の立ち上がり時間tT2T3T4T0舵角δ(時刻0に一度に切る)最後に落ち着くr = KδT秒たつと63%回頭角速度r10
舵を一度に切っても、回頭角速度rはすぐには上がらず、時間Tをかけて最後の値Kδに近づく

下の図では、舵を切った瞬間からのrの上がり方と、そのとき船がどこにいるかを、時刻を動かしながら追える。舵角10°・12ノットで比べると、貨物船(長さ106 m)は約240 m、満載のタンカーは約590 m進んでから、ようやく向きが10°変わる。

KとTは船ごとに決まった数ではなく、船の長さLと速さUで変わる。長さと速さで割ったK'とT'のほうが、同じ船ならおよそ一定になる。

同じ速さなら、長い船ほどTは長い。満載のタンカーの201秒は、T'が大きいことと、船が長いことの両方から来ている。

舵を切り続けると、船はやがて円を描く。その半径は速さUを回頭角速度rで割った値で、r = Kδを入れると速さが消える。

回る円の大きさは、船の長さに比例する。船が2倍の長さになれば、曲がるのに要る海も2倍に広がるわけだ。円の大きさは決まった測り方で比べる。まっすぐ進む船の舵をいっぱいに切り、向きが90°変わった所までに元の針路の向きへ進んだ距離が縦距、横にずれた距離が横距、180°変わった所までの横のずれが旋回径である。

旋回圏の縦距・横距・旋回径元の針路舵を右35°に切る縦距2.8 L横距1.5 L向きが90°変わった所旋回径2.5 L180°変わった所船(長さL)
旋回圏の測り方。舵を切った所から向きが90°変わった所までの縦距と横距、180°変わった所までの旋回径を測る(シミュレーションの満載のタンカー・15ノット)

シミュレーションの船は、2通りのよけ方を使い分ける。1つは、船首から扇形に線を伸ばして前を探り、ふさがっていない線のうち目的地の向きにいちばん近いものを選ぶやり方。もう1つは、海を格子に区切り、岩から安全距離以上離れた格子だけを通る道筋を、目的地まで先に引いてしまうやり方である。どちらにも同じ弱点がある。判断してから船首の向きが変わるまでの間、船はほぼまっすぐ進んでしまうことだ。だから扇で探る船は、少なくとも縦距に安全距離を足しただけ先まで見ていないと間に合わない。道筋を引く船は、道筋の最初を「遅れの間にまっすぐ進む区間」にし、角を船が回れる半径で丸めておく。画面の点線(予定の針路)と、同じ式で先に計算した実線(先読みの軌跡)のずれが、遅れの大きさそのものである。

潮が流れていると、陸から見た船の速さは、水から見た速さUに潮の速さを足したものになる。横から潮を受ける船が目的地に船首を向けたままでいると、少しずつ流されていく。進みたい向きと直角に流れる潮の速さをとすると、船首を潮上へ次の角度γだけ向ければ、まっすぐ進める。この角を流圧差という。

6ノットの船が2.5ノットの潮を真横から受けると、γは約25°になる。

船の後ろに広がるV字の波にも決まりがある。深い海では、V字の片側の角が、船の速さによらず約19.5°になる(ケルビンの角)。波のかたまりは、波の山が進む速さの半分でしか進まない。そのため、どの時刻に出た波も、船から見て次の角の内側に収まる。

真後ろの山と山の間隔(横波の波長)はで、速い船ほど長い。シミュレーションの引き波は、航跡の1点ずつから出た波がこの決まりで広がった位置を計算し、山の線だけを描いている。

ケルビンの引き波の山と19.47°の線19.47°進む向き →横波発散波λ = 2πU²/g
ケルビンの引き波。真後ろの横波と、斜めに出る発散波の山が、19.47°の線の上で折り返す
試す

岩を置いて試す

  1. 広い海の真ん中に岩が1つある。扇の線が岩に届くと、船は舵を切る。右下の計器を見ると、舵角δ(太い線)はすぐに上がるのに、回頭角速度r(細い線)はゆっくりとしか上がらない。先読みの軌跡が岩の外へ曲がるまでにも、少し時間がかかる。
  2. 満載のタンカーと貨物船を、どちらも12ノットで走らせ、同じ420 m先で岩に気づかせる。貨物船は難なくよけるが、タンカーは舵をいっぱいに切っても回りきれず、全速後進をかけても岩に乗り上げる。この速さのタンカーの縦距は約760 mあり、420 mでは足りないのだ。
  3. 満載のタンカーを15ノットで走らせ、舵を右35°に切る。縦距・横距・旋回径が海図に書き込まれ、向きが180°変わるまでに5分ほどかかる。船を小型客船に替えると、縦距を船の長さで割った値はタンカーの2.8倍と小型客船の3.2倍であまり変わらないが、メートルで比べると770 mと120 mほどの違いになる。
  4. S字に曲がった水路を、貨物船が「道筋を引く」よけ方で進む。点線の道筋は水路の中ほどを通るが、船は曲がり角ごとに遅れて外へふくらみ、また戻る。水路の中に岩を置くと、すぐに道筋が引き直される。
  5. 岩がCの字に並び、まっすぐ行くと袋小路に入る。扇で探る小型客船は目の前の岩しか見ないため奥まで入り込み、全速後進をかけても止まりきれずに乗り上げる。よけ方を「道筋を引く」に変えると、袋小路を外から回り込む。
  6. 北から南へ2.5ノットの潮が流れる中を、2隻が6ノットで東へ進む。塗りつぶした船は潮を見込み、船首を約25°北へ向けたまま目的地へまっすぐ進む。輪郭だけの船は船首を目的地へ向けるだけなので南へ流され、岩の列に乗り上げる。右上の輪をドラッグすると、潮の向きを変えられる。
現実では

本物の船の数字

広田実の教科書の表には、実際の船のKとTが並んでいる。

船長さ積み荷速力KT
客船37 m9.7ノット0.143/秒6.2秒
貨物船106 m空荷13.0ノット0.067/秒8.3秒
貨物船106 m満載14.0ノット0.110/秒41.6秒
タンカー245 m空荷17.8ノット0.064/秒93.3秒
タンカー276 m満載16.5ノット0.099/秒201秒

同じ貨物船でも、荷を満載にするとTは8.3秒から41.6秒へ5倍に延びる。重い船ほど回り始めが遅い。シミュレーションの4隻のKとTは、この表のうち空荷の貨物船を除く値を使っている。

旋回圏の大きさには国際的な基準がある。国際海事機関(IMO)が2002年に決めた操縦性の基準では、舵をいっぱいに切ったときの縦距は船の長さの4.5倍以内、旋回径は5倍以内とされた。舵角10°で向きが10°変わるまでに進む距離は2.5倍以内。全速前進から全速後進をかけて止まるまでの航跡の長さは15倍以内で、大きくて機関の力が小さい船は20倍まで認められる1。長さ300 mの船なら、止まるまでに4.5 km進んでも基準の内側ということになる。

米国船級協会(ABS)の手引きは、長さ(垂線間長)349.8 mの満載の大型タンカーを15ノットで回す計算例を載せている。向きが90°変わるまでに173秒、180°変わるまでに409秒かかり、縦距は船の長さの2.77倍、旋回径は2.79倍だった。回っている間に、速さは15ノットから5.33ノットまで落ちる。全速後進で止める計算では、止まるまでに678秒かかり、航跡の長さは船の長さの9.97倍、約3.5 kmになった。

舵そのものの速さにも決まりがある。米国の連邦規則は、主操舵装置が最大の航海速力のまま、舵を片側35°から反対側30°まで28秒以内に動かせることを求めている2。1秒あたり約2.3°なので、まっすぐの状態から35°まで切るだけでも15秒かかる。

シミュレーションの満載のタンカーを15ノットで回すと、縦距は船の長さの2.8倍、旋回径は2.5倍、180°回るまで約5分になる。ABSの計算例とおおむね同じ大きさになるように、大きな舵角で回り方が頭打ちになる度合いと、回ったときの速さの落ち方を選んである。舵取機の速さも、上の規則と同じ1秒に2.32°にした(小型客船だけは1秒に4°)。

省いていること

このシミュレーションが省いていること

  • 本物の船は、回るときに船首を円の内側へ向けたまま、外へすべるように進む(斜航)。野本の1次の式は向きの変わり方だけを表すので、シミュレーションの船は船首の向きにまっすぐ進むものとした。
  • 野本の式は小さな舵角で成り立つ。大きな舵角で回り方が頭打ちになる分は3乗の項を足して、回ると速さが落ちる分は抵抗が速さの2乗に比例する簡単な式で近似した。これらの係数は、旋回圏が上の計算例と同じくらいになるように選んだ値で、特定の船を測ったものではない。
  • 風と波、浅い海、岸のそばで船が受ける力は入れていない。潮の流れは、海のどこでも同じ向きと速さとした。
  • 2通りのよけ方は考え方を見せるための簡単なもので、ほかの船をよける決まり(海上衝突予防法)は入っていない。本物の船では、見張りとレーダー、海図をもとに人が判断する。
  • 引き波は、深い海を進む船が作るケルビンの模様のうち、山の位置だけを描き、波の高さや岩での反射は計算していない。速い小型船では、フルード数が約0.5を超えると、V字の角が19.5°より狭くなることが空から撮った写真をもとに報告されている。
参考文献

参考文献

  1. 広田実『船舶制御システム工学〈増補版〉』p.99, 成山堂書店, 1984.(東京海洋大学の講義資料「船舶の旋回軌跡」に引用された表による) https://www2.kaiyodai.ac.jp/~kurokawa/lecture/ls/m/ls02.pdf
  2. American Bureau of Shipping「Guide for Vessel Maneuverability」2006(2017年2月改訂). https://ww2.eagle.org/content/dam/eagle/rules-and-guides/current/conventional_ocean_service/145_vesselmaneuverability/Vessel_Maneuverability_Guide_e-Feb17.pdf
  3. 米国連邦規則46 CFR 58.25-10「Main and auxiliary steering gear」2023年版, 米国政府出版局. https://www.govinfo.gov/content/pkg/CFR-2023-title46-vol2/pdf/CFR-2023-title46-vol2-sec58-25-10.pdf
  4. Nomoto, K., Taguchi, T., Honda, K., Hirano, S.「On the steering qualities of ships」『International Shipbuilding Progress』4(35), 354–370, 1957.
  5. Thomson, W.「On ship waves」『Proceedings of the Institution of Mechanical Engineers』38(1), 409–434, 1887.
  6. Rabaud, M., Moisy, F.「Ship wakes: Kelvin or Mach angle?」『Physical Review Letters』110, 214503, 2013. https://arxiv.org/abs/1304.2653

注

  1. IMO決議MSC.137(76)「Standards for Ship Manoeuvrability」。数字は参考文献2の表による。 ↩

  2. 46 CFR 58.25-10(b)(2)。 ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、1コマごとに、決まった刻み(0.1秒)で船の運動を何回か進める。刻みのたびに、船は「どちらへ進むか」を判断して舵を命じ、舵取機と野本の式がその舵に遅れてついていく。最後に、今の判断のまま進んだらどうなるかを船の写しで先に計算し、海図・引き波・岩・船・計器を描く。時間は場面ごとに4〜14倍に早めている。

船の数は表から

船の性質は、本文の表にあるK(1/秒)とT(秒)と、その値を測ったときの速力をそのまま持つ。使うときは、長さと速さで割ったとに直してから、今の速さ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は、舵を切ったまま回り続けたときの回頭角速度を、長さと速さで割った形で求める。という3次の式を、ニュートン法(接線で答えに近づくやり方)で解いている。道筋の角を丸めるときの半径は、このからで出す。

舵取機と野本の式

stepShipが船を1刻み進める。順番は、舵 → 回頭 → 速さ → 位置。舵角は命じた角度へ1秒に2.32°(小型客船は4°)ずつしか動かない。回頭は、本文のに3乗の項を足した式をについて解き、刻み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は本文ので、潮を見込む船の船首を潮上へ向ける。

道筋を引く

海を格子に分け、岩から安全距離以上離れた格子だけを通って目的地までいちばん安く行ける道を、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°で回れる半径の円弧で丸める。道筋の最初には、舵が効き始めるまでにまっすぐ進む区間(およそ)を置いている。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番目の山のうち、進む向きから角θの向きへ広がる部分は、秒前に船首から出た波で、そこからだけ進んだ所にある。潮が流れていれば、波も潮といっしょにだけ流される。

引き波(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;}

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/ship-obstacles.ts・1136 行)

読み込んでいます

プログラム全体を見る(src/sims/ship-obstacles-delay.ts・115 行)

読み込んでいます

この記事は、人が監修しながらAIを活用して執筆しています。執筆・監修: 佐々木 航(株式会社Game)

姉妹サイト

株式会社Game が運営するメディアです。