相手を右に見る船が避ける。海の上で船同士がぶつからない決まり

横切る2隻のうち、相手を右舷に見る船が右へ大きく曲がって相手の後ろを通り、もう1隻は針路も速さも変えない。避ける船と保つ船が先に決まっているから、互いの動きが読める。

記事

相手を右に見る船が避ける。海の上で船同士がぶつからない決まり

横切る2隻のうち、相手を右舷に見る船が右へ大きく曲がって相手の後ろを通り、もう1隻は針路も速さも変えない。避ける船と保つ船が先に決まっているから、互いの動きが読める。

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

現象

4分後に衝突した2隻

晴れて見通しのよい午後、全長76 mの貨物船の船長は、左舷の船首から21度の方向、1.2海里(約2.2 km)先に小さな漁船を見つけた。漁船は貨物船の前を、左から右へ横切る向きに進んでいた。船長は一目見て、前を無事に通り過ぎるだろうと考え、コンパスで方位を測らないまま走り続けた。漁船の船長は右前方をちらりと見ただけで、ほかの船はいないと思っていた。

4分後、2隻は衝突した。

海難審判所が、横切り船の航法の裁決例として公開している事故である1。見通しはよく、互いに相手を見られる位置にいて、時間もあった。それでもぶつかったのは、このまま進むと相手とぶつかるかどうかを、2隻とも確かめないまま近づいたからだ。

海には車線も信号もない。大きな船は舵を切ってもすぐには曲がらず、急には止まれない。そこで、出会い方ごとに「避ける船」と「針路と速さを保つ船」を先に決めておく。日本ではその決まりを海上衝突予防法が定めていて、中身は世界共通の国際規則にそろえてある。

仕組み

方位が変わらない相手とは、ぶつかる

どちらが避けるかを決める前に、まず「このまま進むとぶつかるか」を見分けなければならない。海上衝突予防法は、近づいてくる船のコンパス方位に明確な変化が見られないときは衝突するおそれがあると判断するよう求め、レーダーを使う船には、探知した船を系統立てて観察するよう求めている2。

なぜ方位が変わらないとぶつかるのか。自分の船を止めたつもりで相手を眺めると分かる。自船Aから見た相手Bは、2隻の速度の差の向きに、まっすぐ動いて見える。その線がAを通っていれば、Bはいつも同じ方向に見えたまま近づき、最後にAに届く。線がAからずれていれば、Bの見える方向は少しずつ回り、最も近づいたあとで離れていく。

この最も近づいたときの距離を最接近距離、そこまでの時間を最接近までの時間と呼ぶ。Aから見たBの位置を(海里)、2隻の速度の差を(ノット)とする。

時刻には、BはAから見ての位置にいる。この長さがいちばん短くなる時刻と、そのときの距離は次のとおりだ。

言葉で言えば、相手から見たBの動きの線にAから垂線を下ろした長さが最接近距離で、Bがその垂線の足に着くまでの時間が最接近までの時間である。が負なら、最も近づく時刻はもう過ぎていて、2隻は離れつつある。

細い線は、2分ごとにAからBを見た方向である。方位が変わらない組では、どの線も平行に並び、最接近距離は0になる。

Bを進む向きへ0.9海里ずらすと、線は扇のように開く。方位は1分に0.8°ずつ左へ回り、BはAの前を0.48海里で横切る。

題の下のレーダーの画面では、最接近までの時間が「判断を始める時間」より短く、最接近距離が「安全距離」より短い組を、危ない組として太い線と点線の輪で示す。

仕組み

右に見る船が避け、もう1隻は保つ

危ない組だと分かったら、出会い方で役目が決まる。避ける船を避航船、針路と速さを保つ船を保持船と呼ぶ。

行き会い横切り追い越し AB 両方が右へ曲がる A(避航船)B(保持船) 相手を右に見るAが後ろを通る 正横後22.5°より後ろ BA 追い付くAが避ける
行き会い・横切り・追い越しで、避ける船がとる動き

真向かいか、ほとんど真向かいに行き会う2隻は、どちらも針路を右に変えて、互いの左舷側を通る。進路が横切る2隻では、相手を右舷側に見る船が避航船で、やむを得ない場合のほかは相手の船首の前を横切ってはならない。後ろから追い付く船は、相手を確実に追い越して十分に遠ざかるまで、相手の進路を避け続ける。追越し船かどうか分からないときは、追越し船だと考える3。海難審判所の解説は、この土台を「海の上では、右側通行」と「動きやすい船が、動きにくい船を避ける」の2つにまとめている。

追い越しの境目は、相手の正横(真横)から22.5°後ろの線である。半端な角度に見えるが、夜の灯火の境と同じだ。右舷の緑灯と左舷の紅灯は、それぞれ船首から112.5°の範囲を照らし、その後ろ135°は白い船尾灯だけが照らす4。夜でも、相手の舷灯が見えなければ自分は追越し船だと分かる。22.5°は、方位を32に分けた昔の目盛りの2つ分にあたる。

正面(ほぼ真向かい)なら行き会い。両方が右へ 右舷灯(緑)112.5° 左舷灯(紅)112.5° 右舷側に相手が見える → こちらが避航船 左舷側に相手が見える → 相手が避航船 正横 船尾灯(白)135° 正横後22.5°より後ろから近づく船は追越し船(避ける側) 太い実線・破線・点線は、それぞれの灯火が見える範囲
船の灯火が見える範囲と、相手が見える方向ごとの役目

横切りで相手を右舷に見るとき、相手はこちらに左舷を向けているので、見えるのは紅い左舷灯だ。右前に紅い灯が見えたら、こちらが避ける番になる。

避航船は、できる限り早く、大幅に動かなければならない。針路や速さを変えるときは、相手がすぐ気づけるほど大きく変える。広い海では、早めに大きく針路を変えることが最も効く動作になる場合があり、必要なら速力を落とすか、止まる5。小さく何度も曲がると、相手からは何をしているのか読めない。

保持船は、針路と速力を保つ。避航船が「相手はこのまま進む」と見込んで避け方を決められるように、動かないことが役目になる。ただし、避航船が動かないことがはっきりしたら、保持船は自分で衝突を避ける動作をとってよい。横切りのときは、やむを得ない場合のほかは左へ曲がらない。さらに近づいて、避航船の動作だけでは避けきれないと判断したら、最善の協力動作をとらなければならない。相手の動きに疑いがあるときは、汽笛で短音(約1秒)を5回以上鳴らす警告信号もある6。

どの動作も、舵を切ってすぐには効かない。船首が回る速さ(rad/s)は、舵角(rad)に遅れて追いつく。

(秒)は追いつくまでの時間の目安、(1/秒)は舵の効きを表す。野本謙作らが1950年代に示した船の操縦の式で、シミュレーションもこの形で船を回す。シミュレーションでは、長さ150 mの船が12ノットで走るときはおよそ39秒になり、舵角20°で1秒におよそ1°ずつ回る。判断が遅れると、曲がり始める前に距離を使い切ってしまう。

試す

6つの出会いを動かす

画面は上から見たレーダーで、上が北、外側の目盛りが方位である。船は右舷側を塗り、左舷側を白抜きにして描き、矢印の先は10分後に着く位置を表す。

  1. 真向かいに近づくAとBは、最接近までの時間が12分を切ったところで、そろって右へ30°曲がる。互いの左舷を見ながら1.6海里ほど離れて行き違い、最接近を過ぎてから元の線に戻る。両方が同じ決まりで動くので、相手の動きを待たずに済む。
  2. AはBを右舷に見るので避航船になる。右へ40°曲がってBの後ろを通り、Bは針路も速さも変えない。灯火の見え方の箱では、AからBの紅い左舷灯が見えている。
  3. 16ノットのAが、6ノットのBに後ろから追い付く。Aから見えるBの灯火は船尾灯だけで、Aが追越し船になる。Aは右へ30°曲がってBを追い越し、戻ると近づきすぎるあいだは避けた針路のまま走ってから、元の線へ戻る。
  4. 冒頭の裁決例と同じ針路と速さ(Aは053°を12ノット、Bは178°を8ノット)で、16分後に同じ点に着く位置から走り出す。Bは避航船なのに動かない。Aは最接近の約8分前に警告信号を鳴らし(広がる5つの輪)、約5分前になると右へ70°曲がって、1海里の距離を保つ。Bをたたいて選び、もう一度たたいて決まりを守る船に戻すと、Bは危ない組になったところで右へ45°曲がり、Aの後ろを通る。
  5. A・B・Cが、同じ時刻に同じ点へ向かう。AはBとの組では避航船、Cとの組では保持船になり、BとCは行き会いの関係になる。3隻がそれぞれの決まりで右へ曲がると、3組とも1.2海里ほど離れて過ぎる。判断を始める時間を4分に縮めると、動き出しが遅れ、AはBともCとも安全距離の1海里を割る。
  6. 北へ行く船は航路の東側、南へ行く船は西側を通る。すれ違う2隻は初めから1.2海里離れているので、どちらも針路を変えずに済む。航路を横切るFは、北へ行くBが通り過ぎるまで速力を落として待つ。
現実では

日本の海の決まりと、混み合う湾

船の灯火も、広い海での航法も、世界共通である。海上衝突予防法は国際規則(COLREG、1972年)に基づいていて、日本の港に入る外国船も、外国へ向かう日本の船も、同じ決まりで動く。

ただし、船が集まる湾や港には、海上交通安全法や港則法による上乗せの決まりがある。東京湾の入口の浦賀水道航路では、長さ50 m以上の船は航路に沿って航行しなければならず、航路の中央から右側を通り、速力は12ノット(対水速力)を超えてはならない。航路の外から入る船や航路を横切る船は、航路に沿って進む船と衝突するおそれがあれば、その進路を避ける。このとき、航路を進む船の側には横切り船の航法などが当てはまらない7。広い海の「右に見る船が避ける」より先に、「航路を行く船が優先」が来るわけだ。シミュレーションの海峡の場面は、この考え方を借りている。

それでも衝突はなくならない。内閣府の令和7年交通安全白書によると、2024年(令和6年)の船舶事故1,817隻のうち、衝突は330隻で、事故の種類ではいちばん多かった。原因別では見張り不十分が265隻で、不可抗力などを除くと最も多い。見張りや操船の誤りなど人の要因による事故は、全体の約7割を占める。

冒頭の事故の裁決も、責任を片方だけには負わせていない。主な原因は、見張りが足りず避航船として避けなかった漁船にある。一方で、貨物船も方位の変化を見ず、警告信号を鳴らさず、近づいても協力動作をとらなかったことが一因とされた。保持船は、まっすぐ走り続ければよいだけの役ではない。

船乗りの実際の判断は、決まりの文よりさらに手前で始まる。自動で避ける船の安全基準を議論する国土交通省の作業部会に、日本船長協会は2025年、衝突のおそれが生じる前に早めにリスクを下げる操船を求める案を出した。保持船に求められる判断(警告信号や協力動作)を人と同じ水準で機械に任せるのは難しいので、保持船になる状況そのものを作らない、という考え方である。

協会は、延べ1,631人の海技者が操船シミュレーターで評価した結果をもとに、相手との距離と方位の変わる速さで「安全」「注意」「危険」を分ける図を作った。たとえば右舷側から横切ってくる船の方位がほとんど変わらないとき、距離がおよそ2.3海里を切ると「注意」(自分が避け始めるか、相手が避けるのを期待する状況)の領域に入る。海難審判では、決まった航法ではなく、船乗りとして当然の注意(船員の常務)を当てはめた例が多いとも指摘している8。

シミュレーションの「判断を始める時間」を長くすると、どの組も保持船が動く前に片が付く。短くすると、警告信号や最後の手段が増える。

省いていること

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

  • 船の回頭は野本の一次の式で近似した。とは、ふつうの商船を思い描いて選んだ値(無次元で、)を船の長さと速さで戻したもので、積み荷・浅い海・風・潮流による違いは入れていない。速さは4分の時定数で指令の速さに近づくだけで、本物の船が止まるまでの距離は表していない。
  • 「ほとんど真向かい」を互いに船首の左右6°以内、「大幅に」を30°以上の変針とした。安全距離と判断を始める時間も、この画面で決めた目安である。保持船が警告信号を鳴らす時刻と自分で動き出す時刻は、判断を始める時間の7割と4割5分にした。法令に数字は書かれていない。
  • 互いに見えている動力船だけを扱う。視界が悪いときの航法、帆船や漁ろう中の船との関係、無線での打ち合わせ、変針を知らせる汽笛は入れていない。
  • 相手は今の針路と速さのまま進むと見て、最接近距離を出している。本物の船乗りは、相手の向きや灯火の変化から意図も読む。
  • 船の形は見やすいように実際より大きく描いた。海峡の場面は特定の海域ではなく、航路の考え方を借りた形である。
参考文献

参考文献

  1. 海難審判所「海の交通法規入門(横切り船の航法・追越し船の航法・夜の航海 船の灯火・船員の常務)」, 国土交通省. https://www.mlit.go.jp/jmat/monoshiri/houki/houkinyumon/koutsunyumon.htm
  2. 海難審判所「海上衝突予防法 第15条(横切り船)」ほか各条の条文, 国土交通省. https://www.mlit.go.jp/jmat/monoshiri/houki/yobouhou/yobouho15.htm
  3. 海難審判所「裁決事例にみる交通法規 裁決事例1(横切り船の航法)」, 国土交通省. https://www.mlit.go.jp/jmat/monoshiri/houki/saiketsuzireihouki/saizireihouki1.htm
  4. 海上保安庁 東京湾海上交通センター「東京湾海上交通センター利用の手引き」, 2025. https://www6.kaiho.mlit.go.jp/tokyowan/info/pdf/20250401_tokyowantebiki_jp.pdf
  5. 内閣府「令和7年交通安全白書 第2編第1章 海難等の動向」, 2025. https://www8.cao.go.jp/koutu/taisaku/r07kou_haku/zenbun/genkyo/h2/h2s1.html
  6. 日本船長協会「国交省 自動運航船 安全基準・検査WG委員からの提案」(資料2-4), 国土交通省, 2025. https://www.mlit.go.jp/maritime/content/001868305.pdf
  7. Nomoto, K., Taguchi, T., Honda, K., Hirano, S.「On the steering qualities of ships」『International Shipbuilding Progress』4(35), 354-370, 1957.

注

  1. 海難審判所「裁決事例にみる交通法規 裁決事例1」。貨物船は499トン、漁船は4トン(全長12 m)。漁船は貨物船を右舷に見る避航船だった。 ↩

  2. 海上衝突予防法第7条第4項(コンパス方位)、同条第2項(レーダー)。 ↩

  3. 第14条(行会い船)、第15条(横切り船)、第13条(追越し船。どちらか確かめられないときは第13条第3項)。 ↩

  4. 第21条第2項(げん灯は正船首方向から正横後22度30分まで)、第13条第2項(夜間はげん灯のいずれも見えない位置から追い越す船を追越し船とする)。船尾灯の範囲は海難審判所の解説による。 ↩

  5. 第16条(避航船)、第8条第2項・第3項・第5項。 ↩

  6. 第17条第1項〜第3項(保持船)、第34条第5項(警告信号)、第32条第2項(短音)。 ↩

  7. 東京湾海上交通センター利用の手引き(2025年4月)による。航路に沿って航行する船については、海上衝突予防法第9条第2項、第12条第1項、第13条第1項、第14条第1項、第15条第1項前段、第18条第1項(第4号の部分)が適用されない。 ↩

  8. 第39条(注意等を怠ることについての責任)。切迫した危険を避けるために法の規定によらないことができる場合は第38条。 ↩

プログラム
プログラム

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

このシミュレーションは、1秒刻みですべての船を舵と速さの式で動かす。5秒ごとに、すべての船の組の最接近距離を計算し、危ない組に避航船と保持船を割り当て、船ごとに針路と速さを決め直す。航法の決まりは、組の関係を決める部分と、1隻ずつ動きを決める部分に分けてある。レーダーの画面や灯火の箱は、その結果を描いているだけである。

海里とノットで数える

長さは海里、速さはノット(1時間に1海里)、時間は秒で数える。1ノットは1秒に1/3600海里なので、速さにKNを掛けると1秒に進む海里になる。AFTの112.5°は、正横(90°)から22.5°後ろまでの角度で、追越し船かどうかの境と、舷灯が見える範囲の境を兼ねている。HEADONとBIGは法令に数字の無いところを、この画面で決めた値である。

定数と単位(src/sims/ship-encounter.ts の 13〜29 行)
// 長さは海里(1 海里 = 1852 m)、速さはノット(1 時間に 1 海里)、時間は秒、角度はラジアン。針路と方位は北から時計回りに測るconst KN = 1 / 3600;              // 1 ノット = 1/3600 海里/秒const MS = 1852 / 3600;           // 1 ノット = 0.514 m/秒const DEG = Math.PI / 180;const R0 = 6;                     // 船が出入りする海の半径(海里)。画面に見せる半径は「表示の範囲」で 3 か 6 海里const VT = 600;                   // 速力ベクトルの長さ: 10 分(600 秒)で進む距離const AFT = 112.5 * DEG;          // 正横後 22.5°。これより後ろから近づく船は追越し船(舷灯が見える範囲の境と同じ)const HEADON = 6 * DEG;           // 「ほとんど真向かい」とみなす角度(この画面の近似)const BIG = 30 * DEG;             // 「大幅に」とみなす最小の変針(この画面の近似)const K1 = 1.2, T1 = 1.6;         // 野本の式の無次元の値 K'・T'const RUD = 20 * DEG, RUD_HARD = 35 * DEG, RUD_RATE = 2.5 * DEG; // ふだんの舵角・舵いっぱい・舵が 1 秒に動く角度const TU = 240;                   // 速さが指令に近づく時定数(秒)const H = 1;                      // 数値積分の刻み(秒)const DEC = 5;                    // 判断をし直す間隔(秒)const TRAIL = 60, TRAIL_DT = 20;  // 航跡の点の数と間隔(秒)。20 分ぶんconst MAXS = 14;                  // 画面に置ける船の数const WCH = 2.4;                  // 「狭い海峡」の場面の水路の幅(海里)

舵を切ってから曲がるまで

船首が回る速さは、本文の式で舵角に遅れて追いつく。コードのsh.r += (h * (K * sh.delta - sh.r)) / Tがこの式を1秒ぶん進める行で、とはsteerが船の長さと速さから出す。長い船ほど、遅い船ほどが長い。

船の動き(src/sims/ship-encounter.ts の 70〜95 行)
/** 野本の式の K(1/秒)と T(秒): 無次元の K'・T' を、船の長さ L と速さ U で戻す。大きくて遅い船ほど T が長く、舵が効き始めるまで待たされる */function steer(sh: Ship) { const U = Math.max(sh.u, 2) * MS; return { K: (K1 * U) / sh.L, T: (T1 * sh.L) / U }; }function physics(sh: Ship, h: number) {  if (sh.wreck) { sh.u = Math.max(0, sh.u - h * 0.05); sh.r = 0; }  else {    const { K, T } = steer(sh);    // 自動操舵: 針路のずれ e と回頭の速さ r から舵角を決める。強さは船ごとの K・T に合わせ、行き過ぎが小さくなるようにする    const wn = clamp(1 / T, 0.035, 0.12), zeta = 0.9;    const kp = (wn * wn * T) / K, kd = Math.max(0, (2 * zeta * wn * T - 1) / K);    const lim = sh.mode === "last" ? RUD_HARD : RUD;    const e = wrapAngle(sh.psiCmd - sh.psi);    const want = clamp(kp * e - kd * sh.r, -lim, lim);    sh.delta += clamp(want - sh.delta, -RUD_RATE * h, RUD_RATE * h); // 舵は少しずつしか動かない    sh.r += (h * (K * sh.delta - sh.r)) / T;                         // 野本の一次の式 T·dr/dt + r = K·δ    sh.psi = wrapAngle(sh.psi + sh.r * h);    sh.u = Math.max(0, sh.u + (h * (sh.uCmd - sh.u)) / TU);         // 速さはゆっくり指令に近づく  }  sh.x += sh.u * KN * Math.sin(sh.psi) * h;  sh.y += sh.u * KN * Math.cos(sh.psi) * h;}/** 予定の航路(通る線)からのずれ。右にずれていると正 */function xte(sh: Ship) { const fx = Math.sin(sh.route.psi), fy = Math.cos(sh.route.psi); return (sh.x - sh.route.x) * fy - (sh.y - sh.route.y) * fx; }/** 予定の航路に戻る針路: ずれに応じて、最大 35° の角度で線へ寄せる */function routeHeading(sh: Ship) { return wrapAngle(sh.route.psi - clamp(Math.atan2(xte(sh), 0.8), -35 * DEG, 35 * DEG)); }/** 新しい針路に落ち着くまでのおよその時間(舵が効き始めるまでの T と、回頭にかかる時間の半分) */function turnTime(sh: Ship, psi: number) { const { K, T } = steer(sh); return T + (0.5 * Math.abs(wrapAngle(psi - sh.psi))) / Math.max(K * RUD, 1e-3); }

1コマで進める時間は、画面の経過時間に「時間の速さ」を掛けたものである。60倍なら、1/60秒のコマで船の時間が1秒進む。その時間を1秒の刻みで何回にも分けて計算するので、端末の速さで描くコマの数が変わっても、船の動きは同じになる。

舵角は自動操舵が決める。針路のずれに比例して舵を切り、回っている速さのぶんだけ戻すので、行き過ぎが小さい。舵そのものも1秒に2.5°までしか動かない。長さ12 mの漁船を25ノットで走らせてもは1.5秒ほどあり、この形の式は刻みがの2倍より短ければ計算が暴れないので、刻み1秒で足りる。routeHeadingは、予定の線から右にずれていれば左へ寄せる針路を返す。避け終わった船は、これで元の線へ戻る。

最接近距離を出す

cpaは本文の2つの式そのままで、相対位置と相対速度の内積から最接近までの時間を、そのときの位置から距離を出す。predictMinは、自分が針路を変えたと仮定したときの最接近距離である。変針は一瞬では終わらないので、曲がり終えるまでの時間tdを「今の速度で進む区間」とし、そのあとを「新しい速度で進む区間」として、2つの区間の最も近い距離をsegMinで求める。

最接近距離(src/sims/ship-encounter.ts の 97〜126 行)
const vel = (sh: Ship): [number, number] => [sh.u * KN * Math.sin(sh.psi), sh.u * KN * Math.cos(sh.psi)];/** a から見た b の方位(北から時計回り)と、船首から測った相対方位(右舷が正) */const bearing = (a: Ship, b: Ship) => Math.atan2(b.x - a.x, b.y - a.y);const relB = (a: Ship, b: Ship) => wrapAngle(bearing(a, b) - a.psi);/** 2 隻の最接近: 相対位置 r と相対速度 v から、最接近までの時間 t = −(r·v)/|v|² と、そのときの距離 d = |r + v t| */function cpa(a: Ship, b: Ship) {  const [ax, ay] = vel(a), [bx, by] = vel(b);  const rx = b.x - a.x, ry = b.y - a.y, vx = bx - ax, vy = by - ay, v2 = vx * vx + vy * vy;  const t = v2 > 1e-14 ? -(rx * vx + ry * vy) / v2 : 0, tt = Math.max(0, t);  return { t, d: Math.hypot(rx + vx * tt, ry + vy * tt), range: Math.hypot(rx, ry) };}/** 相対位置 r・相対速度 v で 0〜T 秒のあいだに最も近づく距離 */function segMin(rx: number, ry: number, vx: number, vy: number, T: number) {  const v2 = vx * vx + vy * vy, t = clamp(v2 > 1e-14 ? -(rx * vx + ry * vy) / v2 : 0, 0, Math.max(0, T));  return Math.hypot(rx + vx * t, ry + vy * t);}/** 自船が td 秒後から針路 psi・速さ u に変えたとして、horizon 秒先までに、ほかのどの船とも最も近づく距離。相手は今の動きのまま進むと見る */function predictMin(s: S, me: Ship, psi: number, u: number, td: number, horizon: number) {  const [v1x, v1y] = vel(me), ue = u < me.u ? (u + me.u) / 2 : u; // 減速は時間がかかるので、変える前と後の平均の速さで見る  const v2x = ue * KN * Math.sin(psi), v2y = ue * KN * Math.cos(psi);  td = Math.min(td, horizon);  let m = Infinity;  for (const k of s.ships) {    if (k === me) continue;    const [kx, ky] = vel(k), rx = k.x - me.x, ry = k.y - me.y;    m = Math.min(m, segMin(rx, ry, kx - v1x, ky - v1y, td));    m = Math.min(m, segMin(rx + (kx - v1x) * td, ry + (ky - v1y) * td, kx - v2x, ky - v2y, horizon - td));  }  return m;}

小さな図のほうでは、方位の変わる速さも出している。方位を時間で微分すると、相対位置と相対速度の外積を距離の2乗で割った形になる。これが0なら方位が変わらない組である。

最接近の計算(src/sims/ship-encounter-cpa.ts の 12〜21 行)
const vel = (b: Boat): Pt => [b.u * KN * Math.sin(b.psi), b.u * KN * Math.cos(b.psi)];/** A から見た B: 相対位置 r = B − A、相対速度 v = v_B − v_A。最接近までの時間 t = −(r·v)/|v|²、最接近距離 d = |r + v t| */function cpa(s: S) {  const [ax, ay] = vel(s.A), [bx, by] = vel(s.B);  const rx = s.B.x - s.A.x, ry = s.B.y - s.A.y, vx = bx - ax, vy = by - ay, v2 = vx * vx + vy * vy;  const t = v2 > 1e-14 ? -(rx * vx + ry * vy) / v2 : 0, tt = Math.max(0, t);  // 方位の変化: 方位 θ = atan2(rx, ry) の時間変化 dθ/dt = (ry·vx − rx·vy)/|r|²(北から時計回りを正)  const rate = ((ry * vx - rx * vy) / (rx * rx + ry * ry || 1)) * (180 / Math.PI) * 60;  return { t, d: Math.hypot(rx + vx * tt, ry + vy * tt), range: Math.hypot(rx, ry), vx, vy, rate };}

組ごとに役目を決める

decideが危ない組とみなすのは、最接近までの時間が0より長く「判断を始める時間」より短く、しかも最接近距離が「安全距離」より短い組である(すでに安全距離の6割より近い組も含める)。最接近を過ぎて安全距離より離れたら、その組を消す。

classifyは、互いの船首から測った相手の方位で出会い方を決める。相手から見て正横後22.5°より後ろにいれば追い越し、互いに船首の左右6°以内なら行き会い、それ以外は横切りで、相手を右(方位が正)に見る船が避航船になる。関係は危なくなった最初の1回だけ決めて、替わすまで変えない。避け始めた船の向きで方位が変わり、役目が入れ替わらないようにするためである。

航法の判断(src/sims/ship-encounter.ts の 128〜246 行)
/** 組の関係を決める(危なくなった最初の時点で 1 回だけ決め、替わすまで変えない) */function classify(s: S, a: Ship, b: Ship): { kind: Kind; give: Ship[]; stand: Ship | null } {  if (a.wreck || b.wreck) { const m = a.wreck ? b : a; return { kind: "wreck", give: [m], stand: null }; }  if (s.strait && a.ferry !== b.ferry) { const f = a.ferry ? a : b; return { kind: "route", give: [f], stand: f === a ? b : a }; }  const ab = relB(a, b), ba = relB(b, a);  // 追い越し: 相手の正横後 22.5° より後ろから近づく船が避ける(どちら側を通ってもよい)  if (Math.abs(ba) > AFT && Math.abs(ab) <= AFT) return { kind: "overtake", give: [a], stand: b };  if (Math.abs(ab) > AFT && Math.abs(ba) <= AFT) return { kind: "overtake", give: [b], stand: a };  if (Math.abs(ab) > AFT && Math.abs(ba) > AFT) { const f = a.u >= b.u ? a : b; return { kind: "overtake", give: [f], stand: f === a ? b : a }; }  // 行き会い: 互いに相手をほとんど真向かいに見るとき、両方が右へ  if (Math.abs(ab) < HEADON && Math.abs(ba) < HEADON) return { kind: "headon", give: [a, b], stand: null };  // 横切り: 相手を右舷に見る船が避航船、もう 1 隻が保持船  if (ab > 0 && ba < 0) return { kind: "crossing", give: [a], stand: b };  if (ba > 0 && ab < 0) return { kind: "crossing", give: [b], stand: a };  // 互いに相手を同じ側に見るすれ違い: 迷うときは行き会いとみなし、両方が右へ(この画面の決め方)  return { kind: "headon", give: [a, b], stand: null };}function decide(api: Api, s: S) {  const SD = api.num("safe"), TD = api.num("tcpa") * 60;  const B = s.ships;  for (let i = 0; i < B.length; i++) for (let j = i + 1; j < B.length; j++) {    const a = B[i], b = B[j];    if (a.wreck && b.wreck) continue;    const key = `${Math.min(a.id, b.id)}-${Math.max(a.id, b.id)}`, c = cpa(a, b);    // 衝突のおそれ: 最接近までの時間が判断を始める時間より短く、最接近距離が安全距離より短い(すでに近すぎるときも)    const risk = c.t > 0 && ((c.t < TD && c.d < SD) || c.range < SD * 0.6);    let p = s.pairs.get(key);    if (!p && risk) {      p = { key, a, b, ...classify(s, a, b), dcpa: c.d, tcpa: c.t, range: c.range, risk, warned: false };      s.pairs.set(key, p);      if (a === s.sel || b === s.sel) say(api, s, pairText(p), 1);    }    if (!p) continue;    p.dcpa = c.d; p.tcpa = c.t; p.range = c.range; p.risk = risk;    // 最接近を過ぎて安全距離より離れた(替わした)か、2 隻とも止まった    if (c.t <= 0 && (c.range > SD || (a.u < 0.5 && b.u < 0.5))) s.pairs.delete(key);  }  for (const [k, p] of s.pairs) if (!B.includes(p.a) || !B.includes(p.b) || (p.a.wreck && p.b.wreck)) s.pairs.delete(k);  for (const me of B) if (!me.wreck) shipDecide(api, s, me, SD, TD);}function shipDecide(api: Api, s: S, me: Ship, SD: number, TD: number) {  const mine: Pair[] = [];  for (const p of s.pairs.values()) if (p.a === me || p.b === me) mine.push(p);  const other = (p: Pair) => (p.a === me ? p.b : p.a);  if (me.rogue) { me.mode = "rogue"; me.psiCmd = routeHeading(me); me.uCmd = me.route.u; me.action = "針路と速力を変えない(決まりを守らない)"; return; }  // 間近に迫った: 舵いっぱいで、最も離れられる針路と速さをとる(保持船なら最善の協力動作)。切迫した危険を避けるためなので、左へ回ることも許す  const close = mine.find((p) => p.tcpa > 0 && p.tcpa < 180 && p.dcpa < 0.35 * SD && p.range < Math.max(0.6, SD));  if (close || (me.mode === "last" && mine.length)) {    const cur = me.mode === "last" ? predictMin(s, me, me.psiCmd, me.uCmd, turnTime(me, me.psiCmd), 600) : -1;    if (me.mode !== "last" || cur < 0.15) {      const pl = plan(s, me, { sides: s.strait ? [] : [1, -1], minAlt: 20 * DEG, maxAlt: 150 * DEG, base: me.psi, SD, horizon: 600, ahead: [] });      if (me.mode !== "last" || pl.min > cur + 0.02) {        const entering = me.mode !== "last";        if (entering) me.from = me.psi;        me.mode = "last"; me.psiCmd = pl.psi; me.uCmd = pl.u;        const st = mine.some((p) => p.stand === me);        me.action = `${actionText(me)}(${st ? "最善の協力動作" : "切迫した危険を避ける"})`;        if (entering) say(api, s, `${me.name} が舵いっぱいで${actionText(me)}`, 3);      }    }    return;  }  // 避航船: できる限り早く、大幅に動く(第 16 条)  const give = mine.filter((p) => p.give.includes(me));  if (give.length) {    const horizon = Math.max(TD * 1.2, ...give.map((p) => p.tcpa + 240));    const ahead = give.filter((p) => p.kind === "crossing" && p.stand).map((p) => p.stand as Ship);    const over = give.every((p) => p.kind === "overtake");    const first = me.mode !== "give";    const cur = first ? -1 : predictMin(s, me, me.psiCmd, me.uCmd, turnTime(me, me.psiCmd), horizon);    if (first || cur < 0.9 * SD) {      // 狭い海峡の船は針路を変えず、速力で避ける(水路の幅が足りない)      // やり直すときも、避け始めの針路から数えて 90° までにとどめ、足りなければ減速する      const done = first ? 0 : Math.abs(wrapAngle(me.psiCmd - me.from));      const pl = plan(s, me, { sides: s.strait ? [] : over ? [1, -1] : [1], minAlt: first ? BIG : clamp(done + 10 * DEG, BIG, 90 * DEG), maxAlt: 90 * DEG, base: first ? me.psi : me.from, SD, horizon, ahead });      if (first || pl.min > cur + 0.02) {        if (first) me.from = me.psi;        me.psiCmd = pl.psi; me.uCmd = pl.u; me.mode = "give"; me.action = actionText(me);        if (me === s.sel || give.some((p) => other(p) === s.sel)) say(api, s, `${me.name} が${me.action}(避航船)`, 1);      }    }    return;  }  // 保持船: 針路と速力を保つ。避航船が動かないときは警告信号、さらに近づけば自分で避ける(第 17 条)  const stand = mine.filter((p) => p.stand === me);  if (stand.length) {    if (me.mode !== "warn" && me.mode !== "act") { me.mode = "stand"; me.action = "針路と速力を保つ(保持船)"; }    for (const p of stand) {      if (!p.warned && p.tcpa > 0 && p.tcpa < 0.7 * TD && p.dcpa < 0.5 * SD) {        p.warned = true; me.warnT = api.t;        if (me.mode === "stand") { me.mode = "warn"; me.action = "警告信号(短音 5 回以上)を鳴らす"; }        say(api, s, `${me.name} が警告信号を鳴らしました(短音 5 回以上)`, 2);      }      if (me.mode !== "act" && p.tcpa > 0 && p.tcpa < 0.45 * TD && p.dcpa < 0.5 * SD) {        const pl = plan(s, me, { sides: s.strait ? [] : [1], minAlt: 40 * DEG, maxAlt: 120 * DEG, base: me.psi, SD, horizon: TD * 1.2 + 300, ahead: [] });        me.from = me.psi; me.psiCmd = pl.psi; me.uCmd = pl.u; me.mode = "act";        me.action = `${actionText(me)}(保持船の動作)`;        say(api, s, `${other(p).name} が避けないので、${me.name} が${actionText(me)}`, 2);      }    }    return;  }  // 危ない組が無くなった: 戻っても近づきすぎない(安全距離より、すでに近いときは今より近づかない)と分かってから、元の航路へ戻る  if (me.mode !== "cruise" && me.mode !== "resume") {    const psiR = routeHeading(me), was = me.mode;    const m = predictMin(s, me, psiR, me.route.u, turnTime(me, psiR), TD);    let now = Infinity;    for (const k of s.ships) if (k !== me) now = Math.min(now, Math.hypot(k.x - me.x, k.y - me.y));    if (was === "stand" || m >= Math.min(0.95 * SD, now - 0.02)) {      me.mode = was === "stand" ? "cruise" : "resume"; me.psiCmd = psiR; me.uCmd = me.route.u;      me.action = was === "stand" ? "針路と速力を保つ" : "元の針路へ戻る";      if (me === s.sel && was !== "stand") say(api, s, `替わしたので、${me.name} は元の針路に戻ります`, 1);    } else { me.mode = "hold"; me.action = "避けた針路を保つ(戻ると近づくため)"; }    return;  }  me.psiCmd = routeHeading(me); me.uCmd = me.route.u;  if (me.mode === "resume" && Math.abs(xte(me)) < 0.06 && Math.abs(wrapAngle(me.psi - me.route.psi)) < 4 * DEG) me.mode = "cruise";  if (me.mode === "cruise") me.action = "針路と速力を保つ";}

shipDecideは、決まりを守らない船 → 間近に迫った船 → 避航船 → 保持船 → 戻る船の順に調べる。保持船は判断を始める時間の7割で警告信号を鳴らし、4割5分を切っても最接近距離が安全距離の半分に届かなければ、自分で右へ避ける。

避ける針路を順に試す

避航船は、今の針路から右へ30°、35°、40° …と5°ずつ試し、どの船とも安全距離を保てる最初の針路を選ぶ。針路だけで足りなければ速力を半分に、それでも足りなければ止まる。横切りの避航船は、まず相手の船首の前を横切る候補を外して試し、それで見つからないときだけ外さずに試す。

避ける針路を選ぶ(src/sims/ship-encounter.ts の 248〜291 行)
/** 自船が相手の船首の前を横切ることになるか(横切りの避航船は、やむを得ない場合のほかは保持船の船首方向を横切らない) */function crossesAhead(me: Ship, psi: number, u: number, td: number, b: Ship, horizon: number) {  const [v1x, v1y] = vel(me), v2x = u * KN * Math.sin(psi), v2y = u * KN * Math.cos(psi), [bvx, bvy] = vel(b);  const fx = Math.sin(b.psi), fy = Math.cos(b.psi);  const px = me.x + v1x * td - b.x, py = me.y + v1y * td - b.y;  const lat0 = px * fy - py * fx, latV = v2x * fy - v2y * fx;  if (Math.abs(latV) < 1e-9) return false;  const tx = -lat0 / latV;  if (tx < 0 || tx > horizon - td) return false;  const T = td + tx, mx = me.x + v1x * td + v2x * tx, my = me.y + v1y * td + v2y * tx;  return (mx - (b.x + bvx * T)) * fx + (my - (b.y + bvy * T)) * fy > 0;}/** 狭い海峡の場面: 自分の向きの側(右側)の水路から出ないか */function laneOK(s: S, me: Ship, psi: number, u: number, td: number) {  if (!s.strait || me.ferry) return true;  const [v1x] = vel(me), x1 = me.x + v1x * td, x2 = x1 + u * KN * Math.sin(psi) * 480;  const north = Math.cos(me.route.psi) > 0, lo = north ? 0.12 : -(WCH / 2 - 0.15), hi = north ? WCH / 2 - 0.15 : -0.12;  return Math.abs(x1) < WCH / 2 - 0.05 && x2 > lo && x2 < hi;}/** 候補の針路を小さい変針から順に試し、どの船とも安全距離をとれる最初のものを選ぶ。針路だけで足りなければ減速、それでも足りなければ機関を止める。 *  どれも足りなければ、最も離れられるもの(やむを得ない場合は相手の船首方向を横切るものも含める) */function plan(s: S, me: Ship, o: { sides: number[]; minAlt: number; maxAlt: number; base: number; SD: number; horizon: number; ahead: Ship[] }): Plan {  const speeds = o.sides.length ? [1, 0.5, 0] : [0.5, 0.25, 0];  const alts: number[] = [];  if (o.sides.length) for (let a = o.minAlt; a <= o.maxAlt + 1e-9; a += 5 * DEG) alts.push(a); else alts.push(0);  const sides = o.sides.length ? o.sides : [1];  let best: Plan | null = null;  for (const strict of [true, false]) for (const f of speeds) for (const a of alts) for (const side of sides) {    const psi = wrapAngle(o.base + side * a), u = me.route.u * f, td = turnTime(me, psi);    if (!laneOK(s, me, psi, u, td)) continue;    if (strict && o.ahead.some((b) => crossesAhead(me, psi, u, td, b, o.horizon))) continue;    const m = predictMin(s, me, psi, u, td, o.horizon);    if (!best || m > best.min) best = { psi, u, min: m };    if (m >= o.SD * 1.05) return { psi, u, min: m };  }  return best ?? { psi: me.psiCmd, u: me.uCmd, min: 0 };}/** 取った動作の言葉。変針の角度は、避け始めたときの針路から測る */function actionText(me: Ship) {  const alt = Math.round(wrapAngle(me.psiCmd - me.from) / DEG);  let t = Math.abs(alt) >= 3 ? `${alt > 0 ? "右" : "左"}へ ${Math.abs(alt)}° 変針` : "";  if (me.uCmd < me.route.u - 0.1) t += (t ? "し、" : "") + (me.uCmd < 0.5 ? "機関を止めて待つ" : `速力を ${fmt(me.uCmd, 0)} ノットに落とす`);  return t || "針路と速力を保つ";}

触ると描く

矢印の先は10分後の位置なので、取っ手を船から1海里離すと6ノットになる。船を動かしたり針路を変えたりしたときは、その位置と向きを新しい予定の線にする。

操作(src/sims/ship-encounter.ts の 675〜731 行)
pointer(type, api, s) {  const L = s.lay; if (!L) return;  const P = api.pointer, wx = (P.x - L.cx) / L.k, wy = -(P.y - L.cy) / L.k;  const reset = (sh: Ship) => {    sh.route = { x: sh.x, y: sh.y, psi: sh.psi, u: sh.u }; sh.psiCmd = sh.psi; sh.uCmd = sh.u; sh.r = 0; sh.delta = 0;    sh.mode = sh.rogue ? "rogue" : "cruise"; sh.action = "針路と速力を保つ"; sh.tn = 0; sh.nearD = Infinity;    for (const [k, p] of s.pairs) if (p.a === sh || p.b === sh) s.pairs.delete(k);    s.loop = null; s.decT = 0;  };  if (type === "down") {    let best: Ship | null = null, bd = api.u(12);    for (const sh of s.ships) { if (sh.wreck) continue; const [hx, hy] = handlePos(api, L, sh), d = Math.hypot(hx - P.x, hy - P.y); if (d < bd) { bd = d; best = sh; } }    if (best) { s.drag = { kind: "vec", ship: best, x0: P.x, y0: P.y, wx, wy, moved: false }; return; }    bd = api.u(15);    for (const sh of s.ships) { const [X, Y] = scr(L, sh.x, sh.y), d = Math.hypot(X - P.x, Y - P.y); if (d < bd) { bd = d; best = sh; } }    if (best) { s.drag = { kind: "move", ship: best, x0: P.x, y0: P.y, wx, wy, moved: false }; return; }    if (Math.hypot(wx, wy) < L.Rv) s.drag = { kind: "new", ship: null, x0: P.x, y0: P.y, wx, wy, moved: false };    return;  }  const d = s.drag;  if (!d) return;  if (type === "move") {    if (Math.hypot(P.x - d.x0, P.y - d.y0) > api.u(6)) d.moved = true;    if (d.kind === "vec" && d.ship && d.moved) {      // 矢印の先 = 10 分後の位置。向きが針路、長さが速さ(1 海里の長さ = 6 ノット)      const sh = d.ship, dx = wx - sh.x, dy = wy - sh.y;      if (Math.hypot(dx, dy) > 1e-3) sh.psi = Math.atan2(dx, dy);      sh.u = clamp(Math.hypot(dx, dy) / (VT * KN), 2, 25); reset(sh);    } else if (d.kind === "move" && d.ship && d.moved) {      const r = Math.hypot(wx, wy), q = r > L.Rv - 0.05 ? (L.Rv - 0.05) / r : 1;      d.ship.x = wx * q; d.ship.y = wy * q; reset(d.ship);    }    return;  }  s.drag = null;  if (type === "cancel") return;  if (d.kind === "move" && d.ship && !d.moved) {    const sh = d.ship;    if (s.sel !== sh) { s.sel = sh; if (s.tips < 1) { s.tips++; say(api, s, `${sh.name} を選びました。もう一度たたくと、決まりを守らない船に切り替わります`, 1); } return; }    if (sh.wreck) return;    sh.rogue = !sh.rogue; sh.fixedRogue = true; if (!sh.rogue) sh.mode = "cruise"; s.decT = 0;    say(api, s, sh.rogue ? `${sh.name} を、決まりを守らない船にしました` : `${sh.name} は、決まりを守る船に戻りました`, 2);    api.emit("rogue_toggle", { on: sh.rogue });  } else if (d.kind === "new") {    if (s.ships.length >= MAXS) { say(api, s, `船は ${MAXS} 隻までです`, 2); return; }    let hdg = 0, kn = 12;    if (d.moved) { const dx = wx - d.wx, dy = wy - d.wy; hdg = Math.atan2(dx, dy) / DEG; kn = clamp(Math.hypot(dx, dy) / (VT * KN), 2, 25); }    else if (Math.hypot(d.wx, d.wy) > 0.8) hdg = Math.atan2(-d.wx, -d.wy) / DEG;    const sh = mk(s, nextName(s), d.wx, d.wy, hdg, kn, 140);    sh.rogue = s.rnd() * 100 < api.num("rogue");    s.loop = null; s.decT = 0;    const n = s.ships.filter((x) => !x.wreck).length;    if (n > api.num("count")) api.set("count", Math.min(12, n));    say(api, s, `船 ${sh.name} を足しました。矢印の先の四角をドラッグすると、針路と速さが変わります`, 1);    api.emit("add_ship", { n });  }},
描画(src/sims/ship-encounter.ts の 454〜630 行)
function hull(api: Api, X: number, Y: number, psi: number, len: number) {  // 画面の前向き(fx, fy)と右舷向き(sx, sy)。右舷側を塗り、左舷側を白抜きにする  const fx = Math.sin(psi), fy = -Math.cos(psi), sx = Math.cos(psi), sy = Math.sin(psi), W = 0.2;  const P = (a: number, b: number): Pt => [X + (fx * a + sx * b) * len, Y + (fy * a + sy * b) * len];  const all: Pt[] = [P(0.5, 0), P(0.16, W), P(-0.5, W * 0.8), P(-0.5, -W * 0.8), P(0.16, -W)];  api.poly(all, { close: true, fill: "paper", stroke: false });  api.poly([P(0.5, 0), P(0.16, W), P(-0.5, W * 0.8), P(-0.5, 0)], { close: true, fill: "ink", stroke: false });  api.poly(all, { close: true, w: api.u(1.1) });}function arc(api: Api, X: number, Y: number, r: number, b0: number, b1: number, w: number, dash: number[]) {  const c = api.ctx; c.beginPath(); c.arc(X, Y, r, b0 - Math.PI / 2, b1 - Math.PI / 2); c.lineWidth = w; c.strokeStyle = api.ink; c.setLineDash(dash); c.stroke(); c.setLineDash([]);}const shipLen = (api: Api, sh: Ship) => api.u(10 + 7 * clamp((sh.L - 40) / 220, 0, 1));function scr(L: Lay, x: number, y: number): Pt { return [L.cx + x * L.k, L.cy - y * L.k]; }function handlePos(api: Api, L: Lay, sh: Ship): Pt {  const [vx, vy] = vel(sh), [X, Y] = scr(L, sh.x, sh.y);  const [hx, hy] = scr(L, sh.x + vx * VT, sh.y + vy * VT), d = Math.hypot(hx - X, hy - Y), m = api.u(20);  return d >= m ? [hx, hy] : [X + Math.sin(sh.psi) * m, Y - Math.cos(sh.psi) * m];}function sweepField(api: Api, s: S, L: Lay) {  const n = Math.max(16, Math.round((2 * L.R) / [4, 2.5, 2.2][api.quality]));  if (!s.field || s.field.n !== n) {    const ang = new Float32Array(n * n), ins = new Uint8Array(n * n);    for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {      const x = ((i + 0.5) / n) * 2 - 1, y = ((j + 0.5) / n) * 2 - 1, k = j * n + i;      ins[k] = x * x + y * y < 0.995 ? 1 : 0; let a = Math.atan2(x, -y); if (a < 0) a += TAU; ang[k] = a;    }    s.field = { n, ang, ins, v: new Float32Array(n * n) };  }  const f = s.field, sw = ((s.sweep % TAU) + TAU) % TAU;  for (let k = 0; k < n * n; k++) { if (!f.ins[k]) { f.v[k] = 0; continue; } let d = sw - f.ang[k]; if (d < 0) d += TAU; f.v[k] = d < 0.8 ? 0.09 * (1 - d / 0.8) : 0; }  api.field(n, n, f.v, { x: L.cx - L.R, y: L.cy - L.R, w: 2 * L.R, h: 2 * L.R });}function radar(api: Api, s: S, L: Lay) {  const { cx, cy, R } = L, u = api.u, c = api.ctx;  if (!api.reduced && api.quality > 0) sweepField(api, s, L);  const st = L.Rv / 6; // 輪の間隔: 6 海里なら 1 海里、3 海里なら 0.5 海里  for (let n = 1; n < 6; n++) api.circle(cx, cy, n * st * L.k, n % 2 ? { w: u(0.7), dash: [u(1), u(4)] } : { w: u(0.7) });  for (let a = 0; a < 360; a += 30) { const t = a * DEG; api.line(cx + Math.sin(t) * u(7), cy - Math.cos(t) * u(7), cx + Math.sin(t) * R, cy - Math.cos(t) * R, { w: u(0.5), dash: [u(0.8), u(6)] }); }  api.line(cx - u(5), cy, cx + u(5), cy, { w: u(1) }); api.line(cx, cy - u(5), cx, cy + u(5), { w: u(1) });  api.circle(cx, cy, R, { w: u(1.8) });  // 方位の目盛り(外側)  const step = api.quality === 2 && R > 200 ? 1 : 2;  for (const big of [false, true]) {    c.beginPath();    for (let a = 0; a < 360; a += step) {      const isBig = a % 10 === 0; if (isBig !== big) continue;      const t = a * DEG, len = a % 30 === 0 ? u(10) : a % 10 === 0 ? u(6) : a % 5 === 0 ? u(4) : u(2);      c.moveTo(cx + Math.sin(t) * R, cy - Math.cos(t) * R); c.lineTo(cx + Math.sin(t) * (R + len), cy - Math.cos(t) * (R + len));    }    c.lineWidth = big ? u(1.1) : u(0.6); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke();  }  const fs = u(10.5);  for (let a = 0; a < 360; a += 30) { const t = a * DEG; api.text(String(a).padStart(3, "0"), cx + Math.sin(t) * (R + u(20)), cy - Math.cos(t) * (R + u(20)) + fs * 0.36, { size: fs, font: "mono", align: "center" }); }  for (const n of [2, 4]) { const t = 157.5 * DEG, d = n * st; api.label(n === 4 ? `${fmt(d, 0)} 海里` : fmt(d, 0), cx + Math.sin(t) * d * L.k, cy - Math.cos(t) * d * L.k + u(4), { size: u(10), font: "mono", align: "center" }); }}function land(api: Api, L: Lay) {  const u = api.u, RR = L.Rv + 0.3, a = Math.asin(WCH / 2 / RR);  for (const side of [-1, 1]) {    const pts: Pt[] = [];    const b0 = side > 0 ? a : Math.PI + a, b1 = side > 0 ? Math.PI - a : TAU - a;    for (let i = 0; i <= 40; i++) { const b = b0 + ((b1 - b0) * i) / 40; pts.push(scr(L, RR * Math.sin(b), RR * Math.cos(b))); }    api.hatch(pts, { gap: u(7), angle: 45, w: u(0.7) });    const [x0, y0] = scr(L, (side * WCH) / 2, RR), [x1, y1] = scr(L, (side * WCH) / 2, -RR);    api.line(x0, y0, x1, y1, { w: u(1.6) });  }  const [mx, my0] = scr(L, 0, RR), [, my1] = scr(L, 0, -RR);  api.line(mx, my0, mx, my1, { w: u(0.8), dash: [u(6), u(5)] });  const [nx, ny] = scr(L, 0.6, -L.Rv * 0.72), [sx, sy] = scr(L, -0.6, L.Rv * 0.72);  api.label("北へ", nx, ny, { size: u(10), align: "center" }); api.label("南へ", sx, sy, { size: u(10), align: "center" });}function drawPairs(api: Api, s: S, L: Lay) {  const u = api.u, SD = api.num("safe"), TD = api.num("tcpa") * 60;  for (const p of s.pairs.values()) {    if (!p.risk && p.a !== s.sel && p.b !== s.sel) continue;    const [ax, ay] = scr(L, p.a.x, p.a.y), [bx, by] = scr(L, p.b.x, p.b.y);    api.line(ax, ay, bx, by, { w: u(0.8), dash: [u(1.5), u(3.5)] });    if (!(p.tcpa > 0 && p.tcpa < TD * 1.5 && p.dcpa < SD * 1.6)) continue;    const t = p.tcpa, [avx, avy] = vel(p.a), [bvx, bvy] = vel(p.b);    const Pa = scr(L, p.a.x + avx * t, p.a.y + avy * t), Pb = scr(L, p.b.x + bvx * t, p.b.y + bvy * t);    api.line(ax, ay, Pa[0], Pa[1], { w: u(0.9), dash: [u(1), u(3)] }); api.line(bx, by, Pb[0], Pb[1], { w: u(0.9), dash: [u(1), u(3)] });    const C = p.stand === p.a ? Pa : Pb;    if (p.risk) api.circle(C[0], C[1], SD * L.k, { w: u(1.1), dash: [u(5), u(4)] });    api.line(Pa[0], Pa[1], Pb[0], Pb[1], { w: u(p.risk ? 3 : 1.4) });    api.circle(Pa[0], Pa[1], u(3), { fill: "paper", w: u(1.2) }); api.circle(Pb[0], Pb[1], u(3), { fill: "paper", w: u(1.2) });    // 札(選んだ船の組だけ)は、最接近の点から、選んだ船と反対の向きへずらす(船の札と重ならないように)    if (p.a !== s.sel && p.b !== s.sel) continue;    const [sx0, sy0] = p.a === s.sel ? [ax, ay] : [bx, by];    const mx = (Pa[0] + Pb[0]) / 2, my = (Pa[1] + Pb[1]) / 2, ox = mx - sx0, oy = my - sy0, ol = Math.hypot(ox, oy) || 1;    api.label(`${fmt(p.dcpa, 2)} 海里・${fmt(t / 60, 1)} 分`, mx + (ox / ol) * u(22), my + (oy / ol) * u(22) + u(4), { size: u(10.5), font: "mono", align: ox < 0 ? "right" : "left" });  }}function drawShips(api: Api, s: S, L: Lay) {  const u = api.u, SD = api.num("safe");  for (const sh of s.ships) {    const [X, Y] = scr(L, sh.x, sh.y), len = shipLen(api, sh);    for (let i = 0; i < sh.tn; i++) { const k = (sh.ti - 1 - i + TRAIL) % TRAIL, [tx, ty] = scr(L, sh.tx[k], sh.ty[k]); api.dot(tx, ty, u(i < 3 ? 1.2 : 0.8)); }    if (sh === s.sel) {      api.circle(X, Y, SD * L.k, { w: u(0.9), dash: [u(2), u(3)] });      api.circle(X, Y, len * 0.75 + u(6), { w: u(1), dash: [u(1.5), u(2.5)] });      if (api.bool("lights")) {        const r = len * 0.75 + u(13);        arc(api, X, Y, r, sh.psi, sh.psi + AFT, u(2.2), []);                    // 右舷灯(緑)の見える範囲: 実線        arc(api, X, Y, r, sh.psi - AFT, sh.psi, u(2.2), [u(4), u(3)]);           // 左舷灯(紅)の見える範囲: 破線        arc(api, X, Y, r, sh.psi + AFT, sh.psi + TAU - AFT, u(1.4), [u(1), u(3)]); // 船尾灯の見える範囲: 点線      }    }    if (!sh.wreck) {      const [hx, hy] = handlePos(api, L, sh);      api.arrow(X, Y, hx, hy, { w: u(1.1), head: u(6) });      const q = u(3.6); api.rect(hx - q, hy - q, 2 * q, 2 * q, { fill: "paper", w: u(1.1) });    }    hull(api, X, Y, sh.psi, len);    if (sh.rogue) { const q = len * 0.62; api.rect(X - q, Y - q, 2 * q, 2 * q, { w: u(1.1), dash: [u(2), u(2)] }); }    if (sh.wreck) for (let k = 0; k < 8; k++) { const a = (k / 8) * TAU, r1 = len * 0.55, r2 = len * (k % 2 ? 0.8 : 1.05); api.line(X + Math.cos(a) * r1, Y + Math.sin(a) * r1, X + Math.cos(a) * r2, Y + Math.sin(a) * r2, { w: u(1.2) }); }    // 警告信号: 短音 5 回を、広がる 5 つの輪で    const age = api.t - sh.warnT;    if (age >= 0 && age < 2.6) for (let k = 0; k < 5; k++) { const a = age - k * 0.32; if (a > 0 && a < 1.1) api.circle(X, Y, len * 0.6 + u(4) + a * u(26), { w: u(1.1) }); }    const tx = X + (sh === s.sel ? len * 0.75 + u(17) : len * 0.62 + u(5));    api.text(sh.name, tx, Y - u(4), { size: u(12.5), font: "mono", weight: 700 });    const tag = shortTag(sh);    if (tag) api.label(tag, tx, Y + u(10), { size: u(10) });  }}function legend(api: Api, s: S, L: Lay) {  const b = L.legend, u = api.u, fs = u(11), x = b.x;  let y = b.y + u(14);  const mm = Math.floor(s.time / 60), ss = Math.floor(s.time % 60);  api.text(`経過 ${mm} 分 ${String(ss).padStart(2, "0")} 秒`, x, y, { size: u(12.5), font: "mono" });  y += u(21);  hull(api, x + u(9), y - u(4), 90 * DEG, u(15)); api.text("右舷は塗り・左舷は白抜き", x + u(24), y, { size: fs }); y += u(18);  api.arrow(x, y - u(4), x + u(18), y - u(4), { w: u(1.1), head: u(5) }); api.text("10 分で進む距離", x + u(24), y, { size: fs }); y += u(18);  api.line(x, y - u(4), x + u(18), y - u(4), { w: u(3) }); api.text("最接近距離", x + u(24), y, { size: fs }); y += u(18);  api.circle(x + u(9), y - u(4), u(7), { w: u(1), dash: [u(2.5), u(2)] }); api.text(`安全距離 ${fmt(api.num("safe"), 1)} 海里`, x + u(24), y, { size: fs });}/** 選んだ船から見た相手の灯火(夜はこれだけで相手の向きを読む)。マスト灯・舷灯は船首から左右 112.5°、船尾灯は後ろの 135° を照らす */function lights(api: Api, s: S, L: Lay) {  if (!api.bool("lights") || !s.sel) return;  const b = L.lights, u = api.u, o = s.sel;  const mine = [...s.pairs.values()].filter((p) => p.a === o || p.b === o).sort((p, q) => p.tcpa - q.tcpa);  let t: Ship | null = mine.length ? (mine[0].a === o ? mine[0].b : mine[0].a) : null;  if (!t) { let bd = Infinity; for (const x of s.ships) { if (x === o) continue; const d = Math.hypot(x.x - o.x, x.y - o.y); if (d < bd) { bd = d; t = x; } } }  api.text(t ? `${o.name} から見た ${t.name} の灯火` : `${o.name} から見える船はない`, b.x, b.y + u(12), { size: u(11.5) });  // 夜の箱: ink と paper のうち暗いほうで塗り、灯火を明るいほうで描く(反転しても夜に見える)  const lum = (c: [number, number, number]) => 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2];  const night: "ink" | "paper" = lum(api.inkRGB) < lum(api.paperRGB) ? "ink" : "paper", lit: "ink" | "paper" = night === "ink" ? "paper" : "ink";  const bx = b.x, by = b.y + u(20), bw = b.w, bhh = Math.min(u(62), b.h * 0.5);  api.rect(bx, by, bw, bhh, { fill: night, w: u(1) });  api.line(bx + u(4), by + bhh - u(9), bx + bw - u(4), by + bhh - u(9), { w: u(0.7), stroke: lit, dash: [u(1), u(3)] });  if (!t) return;  const th = relB(t, o); // 相手の船首から見た自分の向き(右舷側が正)  const sc = bw * 0.4, zs = (bhh - u(16)) / 0.75, base = by + bhh - u(12), mx = bx + bw / 2;  const px = (sAl: number, lat: number) => mx + (sAl * Math.sin(th) - lat * Math.cos(th)) * sc;  const A = Math.abs(th), seen: string[] = [];  const two = t.L >= 50;  const light = (x: number, z: number, kind: "white" | "green" | "red", tag: string) => {    const y = base - z * zs;    if (kind === "red") api.circle(x, y, u(3.4), { stroke: lit, w: u(1.5) }); else api.dot(x, y, u(kind === "white" ? 3 : 3.4), lit);    if (kind === "white") api.circle(x, y, u(5.5), { stroke: lit, w: u(0.6) });    if (tag) api.text(tag, x, y + u(13), { size: u(9), color: lit, align: "center" });  };  if (A <= AFT) {    light(px(0.25, 0), 0.42, "white", ""); if (two) light(px(-0.2, 0), 0.72, "white", "");    seen.push(two ? "マスト灯 2" : "マスト灯 1");  }  if (th >= -2 * DEG && th <= AFT) { light(px(-0.05, 0.07), 0.2, "green", "緑"); seen.push("右舷灯(緑)"); }  if (th <= 2 * DEG && th >= -AFT) { light(px(-0.05, -0.07), 0.2, "red", "紅"); seen.push("左舷灯(紅)"); }  if (A >= AFT) { light(px(-0.5, 0), 0.12, "white", ""); seen.push("船尾灯"); }  const fs = u(10.5);  api.text(seen.join("・"), bx, by + bhh + u(15), { size: fs });  const side = relB(o, t), g = seen.includes("右舷灯(緑)"), r = seen.includes("左舷灯(紅)");  // 相手のマスト灯が縦に並ぶ(両方の舷灯が見える)= 相手の船首がこちらを向いている。こちらも相手を正面に見ていれば真向かい  const facing = (g && r) || A < HEADON, headOn = facing && Math.abs(side) < HEADON;  const hint = A >= AFT ? `船尾灯だけ: ${t.name} の後ろにいる` : headOn ? "真向かい: 両方が右へ" : facing ? `${t.name} の船首がこちらを向いている`    : r && side > 0 ? `紅灯を右に見る: ${o.name} が避ける側` : g && side < 0 ? `緑灯を左に見る: ${t.name} が避ける側` : "";  if (hint) api.text(hint, bx, by + bhh + u(30), { size: fs });}

右舷側を塗り、左舷側を白抜きにして左右を見分ける。灯火の箱は、相手の船首から見た自分の向きを使い、船首から、右舷への位置にある灯火を、横方向の位置に描く。真正面からは2つのマスト灯が縦に並び、真横からは前後に開いて見える。夜の箱は、白と黒のうち暗いほうで塗るので、表示を反転しても夜に見える。

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

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

読み込んでいます

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

読み込んでいます

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

姉妹サイト

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