船の自動操縦は、ずれに比例する舵だけだと大きな船を蛇行させる

航路からのずれに比例して舵を切る自動操縦は、大きな船を蛇行させ、風や潮に押された分のずれも残す。回る速さと、航路からのずれを足し続けた量も見ると、船は航路に戻ってくる。

記事

船の自動操縦は、ずれに比例する舵だけだと大きな船を蛇行させる

航路からのずれに比例して舵を切る自動操縦は、大きな船を蛇行させ、風や潮に押された分のずれも残す。回る速さと、航路からのずれを足し続けた量も見ると、船は航路に戻ってくる。

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

現象

戦艦の舵角指示器が止まった

1923年9月、米海軍の戦艦ニューメキシコで、試していた自動の舵取り装置に手を加えて据え付け直すと、艦橋の舵角指示器の針が動かなくなった。舵が止まったわけではない。機械は舵を2〜3°しか動かさずに艦の向きを保っていて、5°より小さな動きを示せない指示器には何も出なかったのだ1。

舵を機械に任せた人は、ロシア出身の技術者ニコラス・ミノルスキーである。彼は前の年に、自動で舵をとる装置を数式で調べた論文を出していた。その中には、ずれに比例する分、ずれが変わる速さに比例する分、ずれを足し続けた分の3つを組み合わせる舵のとり方も入っている。今ではPID制御と呼ぶやり方だ。制御の歴史を調べたベネットは、この論文を、制御の理論を数式で論じた初期の論文として、マクスウェルやラウスの仕事と並べている2。

では、潮や風に押される海で、自動操縦は何を見れば決めた航路を保てるのだろうか。答えは、ずれ、船が回る速さ、ずれの積み重ねの3つと、航路の少し先に置く「狙う点」にある。題の下の海では、貨物船がPだけの自動操縦で航路へ戻ろうとして、航路をはさんで蛇行している。舵のとり方を切り替えると、同じ海なのに船の通り道がまるで変わる。

仕組み

Pだけでは、勢いで行き過ぎる

船は舵を切っても、すぐには回らない。1秒あたりに船首の向きが変わる角度を回頭角速度rとすると、rは舵角δに比例する値Kδへ、時間Tをかけて近づいていく。

野本謙作らが1950年代に示した式で、Kは舵の効きの強さ、Tは回る速さが落ち着くまでの遅れを表す。広田実の教科書の表によれば、長さ106 mの満載の貨物船は、速力14ノットでTが41.6秒、Kが0.110/秒になる。式の意味は舵を切っても、大きな船はすぐには曲がらないでもくわしく読める。

自動操縦の仕事は、向けたい針路と今の船首方位の差εを見て、舵角を決めることだ。いちばん素直なやり方は、このずれに比例して舵を切ることで、これをP制御(比例制御)と呼ぶ。

ところが、船首が向けたい針路にそろった瞬間にεは0になり、舵は中央へ戻るのに、船はまだ回っている。Tだけ遅れて動く船は勢いのまま行き過ぎ、今度は反対へ舵を切られる。ミノルスキーは、ずれだけで舵を切るやり方が小さな船ではそれなりに使えても大きな船ではうまくいかない理由を、船が大きくなるほど、回る勢いを止める水の抵抗が慣性ほどには増えないことから説明した2。

操舵手は、この行き過ぎを早めの当て舵で止める。船首が向けたい針路に近づいてきたところで、反対向きの舵を少し当てるのである。自動操縦では、回頭角速度rに比例する分を舵角から差し引いて、同じことをさせる。これがD(微分)の項で、向けたい針路が変わらなければ、rはεが減っていく速さに等しい。

風は、もう1つの難しさを持ち込む。横から受ける風は船首を回そうとし続けるので、まっすぐ進むには、それを打ち消す舵をとり続けなければならない。しかしPとDの舵は、εが0なら0になる。だから船は、打ち消すのに要る舵角がちょうど出るだけのずれを残して釣り合う。ミノルスキーも、ずれとその変わり方だけで舵角を決めるやり方では、一定の風のように働き続ける力の影響は消えないと書いた2。

そこで、εを足し続けた量に比例する分を加える。I(積分)の項だ。ずれが残る限り積分は増え続け、風を打ち消す舵角に届いてずれが0になると、増えるのをやめる。

Kp(倍)、Ki(1/分)、Kd(秒)をゲインと呼び、つまみで変えられる。積分には上限も付く。大きく曲がるあいだに積分がたまりすぎると、曲がり終えたあとも舵が戻らず、大きく行き過ぎるからだ。この現象をワインドアップという。

P・PD・PIDで舵をとる貨物船の、航路からのずれの移り変わり 航路からのずれ e(m、右が正) 流れのない海で、針路が40°ずれた所から -200 0 +200 0 5 10 15 20 経過(分) P PD 北から18 m/秒の風 -30 0 +30 0 5 10 15 20 経過(分) PD PID
シミュレーションの貨物船の、航路からのずれ。左は流れのない海で針路が40°ずれた所から、Pだけ(破線)とPD。右は北から18 m/秒の風を受けたときのPDとPID
仕組み

航路の先を狙い、潮の分を積み上げる

ただ、PIDが見ているのは、向けたい針路とのずれεだけだ。潮に横から押されると、船は決めた向きを保ったまま、横へ流されていく。航路そのものを保つには、航路からのずれに応じて、向けたい針路を決め直す仕組みがもう1つ要る。

シミュレーションの自動操縦は、船の真横にあたる航路の上の点から、さらにΔ先に「狙う点」を置き、そこへ船首を向けようとする。この決め方はLOS誘導と呼ばれる。LOSはLine of Sight(見通し線)の頭文字だ。航路から右へeずれていれば、向けたい針路は航路の向きψpより左へ傾く。

Δは船の長さの2.5倍、貨物船なら265 mにした。P・PD・PIDの船も、この点を狙って舵をとる。ただしPだけの舵では、ΔがUTより短いと蛇行が収まらない。UTは速さU(m/秒)の船がT秒で進む距離で、ずれが小さいとして線形にした式を調べると、この条件が出てくる。貨物船はUTが約300 mでΔより長く、小型客船は約31 mしかないので、Pだけでも落ち着く。

横から潮を受けた船は、船首を潮上へ角γだけ向けたときに、航路に沿ってまっすぐ進める。航路に直角な潮の速さをとすると、になる。ところがLOSの船は、狙う点の向きがγだけ傾かない限り、船首をγだけ傾けない。つまり航路からずれてはじめて、船は潮に向き合えるのだ。そのずれは、狙う点までの直角三角形から決まる。

14ノットの貨物船が2ノットの潮を真横から受けると、γは約8.2°、ずれは約38 mになる。意外なことに、この釣り合いにはPIDの積分が効かない。釣り合ったとき船首は狙う点を向いていて、εは0だからである。

積分が消せるのは、足し続けているずれだけだ。そこで、航路からのずれeを足し続け、その量に応じて狙う点を潮上へずらす。積分つきLOS(画面では「積分LOS」)と呼ばれるやり方で、2008年に論文で示された3。

LOSで航路の先の点を狙う船が、横からの潮を受けて釣り合うときの形 航路 航路の向き e Δ 狙う点 陸から見て進む向き(航路と平行) γ 船 横からの潮
LOSで航路のΔ先を狙う船が、横からの潮を受けて釣り合う形。船首は潮上へγだけ向き、航路からeずれた所を航路と平行に進む

最後の舵のとり方は、決まった式を持たない。航路からのずれe、航路の向きに対する船首の向き(画面の「針路のずれ」)、回頭角速度rの3つを受け取り、舵角を返す小さなニューラルネットである。中間の層に6個のニューロンがあり、31個の重みで答えが決まる。その重みは、人が決めずに育てる。

シミュレーションでは、進化戦略と呼ばれる方法のいちばん簡単な形で育てる4。今の重みに小さな乱数を足した「子」を8つ作り、どれにも同じ3つの航海を走らせる。航路から右へ船の長さの3倍ずれた所から始める航海、針路が35°ずれた所から横からの潮と風の中を進む航海、途中で左へ60°曲がる航海の3つだ。ずれの2乗の平均に、舵を大きく切った分と動かし続けた分を足した点数で比べ、親より点数の小さい子がいれば、いちばん小さい子を次の親にする。これを1世代と数え、120世代くり返す。25世代たっても点数が悪いままなら、別の乱数の重みから育て直す。

開いたときのAIは、この育て方で貨物船を120世代育てた重みを、数字としてプログラムに書き込んだものである。「AIを育て直す」を押すと、乱数で決めた重みから、画面の上で育て始める。1回目は書き込んだ重みと同じ乱数の種から始まるので、120世代で同じ重みにたどり着く。

試す

6つの海で舵を任せる

  1. 流れのない海で、貨物船がPだけで舵をとる。針路が40°ずれた所から航路へ戻ろうとして、航路をはさんで蛇行を始め、舵は左右35°のあいだを往復し続ける。貨物船では、UTがΔより長いからである。制御をPDに替えると、2回ほど揺れて航路にのる。船を小型客船にすると、Pだけでも落ち着く。
  2. 北から18 m/秒の風を、PDの貨物船が受ける。舵は右へ6°ほど切ったままになり、船は航路の北、風上側に25 mほどずれて進む。PとDの舵は、ずれが残らないと風を打ち消す舵角を出せないからだ。制御をPIDにすると、数分かけてずれが0のまわりに戻り、成績表のずれも半分ほどになる。海をたたくと、そちらから突風が吹く。
  3. 南から北へ2ノットの潮が流れる。PIDの船も北へ35 mほど流された所で釣り合い、船首は潮上へ8°ほど向いたままだ。制御を積分LOSに替えると、狙う点が少しずつ航路の南へずれ、ずれが0に近づく。成績表でも、積分LOSのずれはPIDの半分以下になる。
  4. 比例ゲインを8倍、微分ゲインを60秒に上げる。まっすぐ進むうちは航路にぴたりとのるが、曲がり角を過ぎると舵が左右いっぱいに振れ続け、航路の左右へ150〜250 mほど振れる蛇行になる。舵取機は1秒に2.32°しか舵を動かせず、反対側へ切り返すのに30秒かかる。そのあいだに船が回り過ぎるのである。比例ゲインを1.5倍、微分ゲインを30秒に戻すと、角を過ぎたあとの揺れは数回で収まる。
  5. 乱数で決めた重みから、AIを育てる。左上の箱で世代が進むにつれ、育てる海でのずれが333 mから65 mほどまで下がり、画面の船も航路にのるようになる。120世代までは、1秒に60コマ描ける端末で20秒ほどだ。育ち終えたら、船を満載のタンカーに替えてみるとよい。成績表のAIのずれは500 mほどになり、PIDの2倍を超える。AIは貨物船の動きしか知らないからだ。貨物船に戻し、潮の輪の矢印を向きはそのままに縁まで伸ばして4ノットにすると、AIのずれは23 mから90 mほどに増え、積分LOSの2倍になる。育てた海の潮は1.2ノットまでだった。
  6. S字に曲がった水道を、積分LOSの貨物船が抜ける。水道に沿って流れる2ノットの潮は、中ほどで速く、岸に近いほど遅い。航路は水道の中心線の上に800 mおきに置いた点を結ぶ折れ線で、船は角を回るたびに中心から30 mほど外れるが、岸までは260 mある。制御をPに替えると、蛇行が角ごとに大きくなり、岸に乗り上げる。
現実では

嫌われた自動操舵が、義務になるまで

ニューメキシコの試験はうまくいったが、装置はそのあと取り外され、米海軍は1930年代まで自動の操舵から手を引いた。米海軍のハワード大佐は、海上の乗組員が自動の操舵に「はっきりと、強く反対した」と説明している1。

商船では、事情が違った。スペリー社の社史によれば、商船向けの自動操縦であるジャイロパイロットの実地試験は1922年に始まった。大西洋を初めて自動の舵だけで渡った船は、スタンダード石油のタンカー、W.H.ティルフォード号とされる。船長は「アイアン・マイク」と呼ばれたこの装置を初めは信用していなかったが、横断が始まるとすぐに考えを改めたという5。スペリー社の装置は、ずれに比例する舵と、行き過ぎを止める舵を組み合わせたもので、1937年にミノルスキーがその仕組みを数式で読み解いた2。

今では、大きな船に自動操縦を備えることが条約で決まっている。海上における人命の安全のための国際条約(SOLAS条約)の航海設備の規則は、2002年7月1日以降に建造された総トン数1万トン以上の船に、針路か航跡を自動で保つ装置を備えるよう求めている6。

航跡を保つ装置には、性能と試験の方法を定めた国際規格IEC 62065がある。2014年の第2版は、付属書にある船のモデルを見直し、係数をニュートンの運動の法則に沿った動きに近づけ、潮流も入れた。規格は2025年にも改められている。

舵の速さにも決まりがある。米国の連邦規則は、主操舵装置が、最も深い喫水で最大の速力で進んでいるときに、舵を片側35°から反対側30°まで28秒以内で動かせることを求めている7。シミュレーションの貨物船とタンカーの舵取機は、この速さ(1秒に2.32°)で動く。

省いていること

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

  • 船の回頭は、野本の1次の式に3乗の項を足し、大きな舵角では回る速さが頭打ちになる形で計算している。横流れは回頭の速さと風だけから決まる簡単な形で、速力は一定のまま、回っても落ちない。
  • 風が船首を回す力と風下へ流される量、波による向きの揺れの大きさ、横流れを決める転心の位置は、このシミュレーションで選んだ値である。特定の船を測ったものではない。自動操縦は、波で揺れた船首の向きをそのまま受け取って舵を決める。
  • 狙う点までの距離Δは船の長さの2.5倍に固め、区間の切り替えも簡単な決め方にした。ほかの船や浅い所をよける判断は入っていない。
  • 潮の流れは、海全体で同じ向きと速さか、水道に沿って岸ほど遅くなる形だけにした。浅い水で舵の効きが変わることや、岸に船が引き寄せられる力も入っていない。
  • AIは31個の重みを持つ小さなニューラルネットで、3つの航海だけで育てた。実際の船で使われている装置の仕組みとは違う。
参考文献

参考文献

  1. Minorsky, N.「Directional stability of automatically steered bodies」『Journal of the American Society for Naval Engineers』34(2), 280–309, 1922.
  2. Bennett, S.「Nicholas Minorsky and the automatic steering of ships」『IEEE Control Systems Magazine』4(4), 10–15, 1984.
  3. Nomoto, K., Taguchi, T., Honda, K., Hirano, S.「On the steering qualities of ships」『International Shipbuilding Progress』4(35), 354–370, 1957.
  4. 広田実『船舶制御システム工学〈増補版〉』p.99, 成山堂書店, 1984.(東京海洋大学の講義資料「船舶の旋回軌跡」に引用された表による) https://www2.kaiyodai.ac.jp/~kurokawa/lecture/ls/m/ls02.pdf
  5. Børhaug, E., Pavlov, A., Pettersen, K. Y.「Integral LOS control for path following of underactuated marine surface vessels in the presence of constant ocean currents」『2008 47th IEEE Conference on Decision and Control』4984–4991, 2008.
  6. Beyer, H.-G., Schwefel, H.-P.「Evolution strategies – A comprehensive introduction」『Natural Computing』1(1), 3–52, 2002.
  7. IEC「IEC 62065:2014 Maritime navigation and radiocommunication equipment and systems – Track control systems – Operational and performance requirements, methods of testing and required test results」2014. https://webstore.iec.ch/en/publication/6431
  8. 日本財団図書館「平成14年度 ポートステートコントロール検査官の資質向上事業報告書」ANNEX 5(SOLAS第V章第19規則の改正文). https://nippon.zaidan.info/seikabutsu/2002/00204/contents/012.htm

注

  1. 参考文献2による。試験は1923年に行われ、報告は1930年に出た。試験の舵は、初めはずれと回る速さで決め、あとから回る速さの変わり方を加えた。指示器が示せる最も小さな動きは5°で、実際の舵の動きは2〜3°を超えなかった。ハワード大佐の言葉は、1934年に英国の造船学会で発表された論文への討論にある。 ↩ ↩2

  2. ミノルスキーの1922年の論文(参考文献1)と、1937年に英国の技術誌The Engineerで発表した解析の内容は、参考文献2の解説による。 ↩ ↩2 ↩3 ↩4

  3. 参考文献5。シミュレーションでは、足し続けたずれに船の速さUを掛けて長さの単位にそろえ、その0.25倍だけ狙う点をずらす。参考文献5の式に直すと、積分の強さσを速さの0.25倍(貨物船で約1.8 m/秒)にしたことにあたる。 ↩

  4. 参考文献6。親を上回った子が8つのうち2割より多ければ乱数の幅を広げ、少なければ狭める(1/5成功則)。シミュレーションもこの決め方で幅を変える。 ↩

  5. スペリー・マリン社「Timeline of Sperry Gyrocompass」。 https://www.sperrymarine.com/about-us/timeline-of-sperry-gyrocompass/ ↩

  6. SOLAS条約第V章第19規則2.8.2(参考文献8)。 ↩

  7. 46 CFR 58.25-10(b)(2)(2023年版)。 https://www.govinfo.gov/content/pkg/CFR-2023-title46-vol2/pdf/CFR-2023-title46-vol2-sec58-25-10.pdf ↩

プログラム
プログラム

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

このシミュレーションは、1コマごとに、0.2秒の刻みで「いまの区間を決める → 舵を命じる → 船を進める」を何回かくり返す。1コマの計算の予算が余れば、その残りで成績表の航海とAIの育成を少しずつ進める。最後に海図と計器を描く。

船の数は表から

船の長さ・幅・K・T・速力は、本文の広田実の表の値をそのまま持つ。速力は表の値のまま変えないので、KとTも表の値のまま使える。3乗の項の係数a3、舵取機の速さrate、風の効きhelm15とlee15は、このシミュレーションで選んだ値である。

定数と船(src/sims/ship-autopilot.ts の 23〜57 行)
const KN = 0.514444; // 1 ノット = 0.514 m/秒const DEG = Math.PI / 180;export const RUD = 35 * DEG; // 舵角の上限(左右 35°)const H = 0.2; // 運動を進める刻み(秒)。いちばん小さい T(6.2 秒)の 30 分の 1const XP = 0.25; // 転心の位置: 船の中央から前へ 0.25·Lconst LOOK = 2.5; // LOS で狙う点までの距離 Δ = 2.5·Lconst SIGMA = 0.25; // 積分つき LOS の積分の強さ σ export interface ShipType {  key: string; label: string;  L: number; B: number; // 長さ・幅(m)  K: number; T: number; kn: number; // 表の K(1/秒)・T(秒)と、その値を測ったときの速力(ノット)。速力はこの値のまま走る  a3: number; // 3 乗の項の係数 α'(長さと速さで割った形)  rate: number; // 舵取機が舵を動かす速さ(°/秒)  helm15: number; lee15: number; // 真横から 15 m/秒の相対風を受けたとき: 船首を回す力と同じ効きの舵角(°)と、風下へ流される角度(°)}// 長さ・幅・K・T・速力は表の値。a3・舵取機の速さ・風の効きは、このシミュレーションで選んだ値export const SHIPS: Record<string, ShipType> = {  ferry: { key: "ferry", label: "小型客船", L: 37, B: 7.8, K: 0.143, T: 6.2, kn: 9.7, a3: 0.3, rate: 4, helm15: 6, lee15: 1.5 },  cargo: { key: "cargo", label: "貨物船", L: 106, B: 16.2, K: 0.11, T: 41.6, kn: 14, a3: 1.1, rate: 2.32, helm15: 4, lee15: 0.6 },  tanker: { key: "tanker", label: "満載のタンカー", L: 276, B: 43, K: 0.099, T: 201, kn: 16.5, a3: 3.1, rate: 2.32, helm15: 2, lee15: 0.3 },};export const speedOf = (t: ShipType) => t.kn * KN;const rpMemo = new Map<string, number>();/** 舵角 d を切ったまま回り続けたときの r'(= r·L/U)。r' + α'·r'³ = K'·δ をニュートン法で解く。旋回の半径は L ÷ r' */export function steadyRp(t: ShipType, d: number) {  const key = t.key + d.toFixed(4), m = rpMemo.get(key);  if (m !== undefined) return m;  const c = ((t.K * t.L) / speedOf(t)) * 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);  r = Math.max(r, 1e-3);  rpMemo.set(key, r);  return r;}

steadyRpは、舵を切ったまま回り続けたときの回頭角速度を、長さと速さで割った形で求める。を、接線で答えに近づくニュートン法で解く。航路の区間を切り替える手前の距離に、この旋回の半径を使う。

風と波と潮

風速は、周期41〜150秒の3つの正弦波を重ねて、平均の±20%ほどでゆっくり変える。海をたたいて起こした突風は、の形で強まって弱まる風を足す。波は、船首の向きを周期7〜12秒で揺らす量として作る。船のゆっくりした動きの計算には入れず、羅針儀で測る向きと回頭の速さにだけ重ねる。

外乱(src/sims/ship-autopilot.ts の 59〜113 行)
/** 狭い水道: 中心線 y = amp·sin(2πx/wl)、半分の幅 hw(m) */export interface Chan { amp: number; wl: number; hw: number; x0: number; x1: number }export interface Gust { t0: number; dur: number; from: number; dv: number }export interface Env {  wind: number; windFrom: number; // 平均の風速(m/秒)と、風が吹いてくる方位(rad。北から右回り)  cur: number; curTo: number; // 潮の速さ(m/秒)と、流れていく方位(rad)  chan: Chan | null; // 狭い水道(あれば、流れは水道に沿い、岸に近いほど遅い)  ph: Float64Array; // 突風と波の位相(決まった種の乱数で作る)  gust: Gust | null; // 海をたたいて起こした突風}const GUST_P = [150, 71, 41], GUST_A = [0.5, 0.3, 0.2]; // 風速のゆっくりした変化(周期・秒と重み)const WAVE_P = [7.3, 9.1, 11.7], WAVE_A = [0.5, 0.32, 0.18]; // 波による向きの揺れ(周期・秒と重み)export function makeEnv(rnd: () => number, o: Partial<Env> = {}): Env {  const ph = new Float64Array(6);  for (let i = 0; i < 6; i++) ph[i] = rnd() * TAU;  return { wind: 0, windFrom: 0, cur: 0, curTo: 0, chan: null, gust: null, ...o, ph };}const WV = new Float64Array(2), CV = new Float64Array(2), YV = new Float64Array(2);/** 時刻 tm の風の速度(風が吹いていく向き・m/秒)を WV に入れる */export function windAt(w: Env, tm: number) {  let g = 0;  for (let i = 0; i < 3; i++) g += GUST_A[i] * Math.sin((TAU * tm) / GUST_P[i] + w.ph[i]);  const V = w.wind * (1 + 0.2 * g);  WV[0] = -V * Math.sin(w.windFrom); WV[1] = -V * Math.cos(w.windFrom);  const G = w.gust;  if (G && tm > G.t0 && tm < G.t0 + G.dur) {    const k = Math.sin((Math.PI * (tm - G.t0)) / G.dur) ** 2;    WV[0] -= G.dv * k * Math.sin(G.from); WV[1] -= G.dv * k * Math.cos(G.from);  }  return WV;}/** 波による向きの揺れ(rad)と、その速さ(rad/秒)を YV に入れる。大きさは風速の 2 乗に比例し、小さな船ほど大きい(2° まで) */export function waveAt(w: Env, tm: number, L: number) {  const A = Math.min(2, 0.0005 * w.wind * w.wind * Math.sqrt(100 / L)) * DEG;  let p = 0, q = 0;  for (let i = 0; i < 3; i++) { const om = TAU / WAVE_P[i], a = tm * om + w.ph[3 + i]; p += WAVE_A[i] * Math.sin(a); q += WAVE_A[i] * om * Math.cos(a); }  YV[0] = A * p; YV[1] = A * q;  return YV;}/** 水道の中心線の高さと傾き */export const chanY = (c: Chan, x: number) => c.amp * Math.sin((TAU * x) / c.wl);const chanSlope = (c: Chan, x: number) => ((c.amp * TAU) / c.wl) * Math.cos((TAU * x) / c.wl);/** 位置 (x, y) の潮の流れ(m/秒)を CV に入れる */export function curAt(w: Env, x: number, y: number) {  if (w.cur <= 0) { CV[0] = 0; CV[1] = 0; return CV; }  let dx = Math.sin(w.curTo), dy = Math.cos(w.curTo), f = 1;  const c = w.chan;  if (c) {    // 水道の中では、流れは中心線に沿い、中ほどで最も速く、岸で 0 になる(放物線の形)    const s = chanSlope(c, x), l = Math.hypot(1, s), sg = dx >= 0 ? 1 : -1, q = (y - chanY(c, x)) / c.hw;    f = Math.max(0, 1 - q * q); dx = sg / l; dy = (sg * s) / l;  }  CV[0] = w.cur * f * dx; CV[1] = w.cur * f * dy;  return CV;}

突風と波の位相は、場面ごとに決まった種の乱数で作る。だから成績表の航海も、画面の船と同じ時刻に同じ揺れを受ける。狭い水道の潮は、中心線に沿う向きで、速さは倍にした。

舵取機と野本の式に、横流れを足す

stepShipが船を1刻み進める。順番は、舵 → 風 → 回頭 → 横流れ → 位置である。舵角は命じた角度へ、1秒にrate度ずつしか動かない。

船の運動(src/sims/ship-autopilot.ts の 115〜147 行)
/** 船の状態。x は東、y は北(m)。psi は船首方位(北から右回りの rad。波の揺れを除いたゆっくりした動き)。v は右舷へ向かう横の速さ(m/秒) */export interface Hull { t: ShipType; x: number; y: number; psi: number; r: number; v: number; delta: number; cmd: number }/** 船を h 秒進める */export function stepShip(b: Hull, h: number, w: Env, tm: number) {  const t = b.t, U = speedOf(t), A = t.a3 * (t.L / U) ** 2;  // 舵取機: 舵角は命じた角度(cmd)へ、1 秒に t.rate 度ずつしか動かない  const dm = t.rate * DEG * h;  b.delta += clamp(b.cmd - b.delta, -dm, dm);  const sp = Math.sin(b.psi), cp = Math.cos(b.psi);  const cur = curAt(w, b.x, b.y), cx = cur[0], cy = cur[1];  // 船から見た風 = 風の速度 − 陸から見た船の速度。γ は風が来る向き(船首から右回り)  const wind = windAt(w, tm);  const gx = U * sp + b.v * cp + cx, gy = U * cp - b.v * sp + cy;  const ax = wind[0] - gx, ay = wind[1] - gy, q = (ax * ax + ay * ay) / (15 * 15);  const sg = Math.sin(Math.atan2(-ax, -ay) - b.psi);  const dw = t.helm15 * DEG * q * sg; // 船首を風上へ回す(右から吹けば右へ)  const vLee = -U * t.lee15 * DEG * q * sg; // 風下へ流される(右から吹けば左へ)  // 野本の式 T·dr/dt + r + α·r³ = K·(δ + δw) を dr/dt について解き、h 秒ぶん進める  b.r += (h * (t.K * (b.delta + dw) - b.r - A * b.r * b.r * b.r)) / t.T;  b.psi = (b.psi + h * b.r + TAU) % TAU;  // 横流れ: 転心で横の動きが 0 になる値(−0.25·L·r)にリーウェイを足した値へ近づく  b.v += (h * (-XP * t.L * b.r + vLee - b.v)) / (0.4 * t.T);  // 位置: 水に対する速さ(前へ U・右へ v)に、潮の流れを足す  b.x += h * (U * sp + b.v * cp + cx);  b.y += h * (U * cp - b.v * sp + cy);}const HULLPTS = [[1, 0], [-1, 0], [0, 1], [0, -1]];/** 船首・船尾・両舷のどこかが水道の岸を越えたら座礁 */export function aground(c: Chan, b: Hull) {  const sx = Math.sin(b.psi), sy = Math.cos(b.psi), hl = b.t.L / 2, hb = b.t.B / 2;  for (const [a, k] of HULLPTS) { const x = b.x + sx * a * hl + sy * k * hb, y = b.y + sy * a * hl - sx * k * hb; if (Math.abs(y - chanY(c, x)) > c.hw) return true; }  return false;}

風の力は、船から見た風で決める。風の速度から、陸から見た船の速度を引くと、船から見た空気の動く向きと速さが出る。船首を回す力は同じ効きの舵角に直して、野本の式の舵角に足す。式をについて解き、刻みを掛けて足す(オイラー法)。刻み0.2秒は、いちばん小さいT(6.2秒)の30分の1で、このくらい細かければ答えがぶれない。横の速さは、転心で横の動きが0になる値と、リーウェイを足した値へ、の遅れで近づける。

狙う点と舵角

legは、いまの区間から見た船の位置を、区間に沿って進んだ距離と航路からのずれ(右が正)に分ける。switchAtは、区間の終わりのどれだけ手前で次の区間へ移るかを決める。舵角20°で回る円で角を回る分と、舵が効き始めるまでの遅れの分を足した距離である。

航路と制御(src/sims/ship-autopilot.ts の 149〜207 行)
export type Ctl = "hand" | "p" | "pd" | "pid" | "ilos" | "ai";/** kp(倍)・ki(1/秒)・kd(秒)・lim(積分で足せる舵角の上限・rad) */export interface Gains { kp: number; ki: number; kd: number; lim: number }/** 航路をたどる状態: いまの区間 k・PID の積分 I・積分つき LOS の積分 yi・航路からのずれ e(右が正・m)・区間の向き psiP・向けたい針路 psiD */export interface Nav { k: number; I: number; yi: number; done: boolean; e: number; s: number; psiP: number; psiD: number; dpsi: number; aim: number }export const newNav = (): Nav => ({ k: 0, I: 0, yi: 0, done: false, e: 0, s: 0, psiP: 0, psiD: 0, dpsi: 0, aim: 0 });const LG = { len: 1, s: 0, e: 0, psi: 0 };/** 航路の k 番目の区間(点 k → 点 k+1)から見た船の位置: 区間に沿って進んだ距離 s と、航路からのずれ e(右が正) */function leg(R: number[], k: number, x: number, y: number) {  const ax = R[2 * k], ay = R[2 * k + 1], dx = R[2 * k + 2] - ax, dy = R[2 * k + 3] - ay;  const len = Math.max(1e-6, Math.hypot(dx, dy)), ux = dx / len, uy = dy / len;  LG.len = len; LG.s = (x - ax) * ux + (y - ay) * uy; LG.e = (x - ax) * uy - (y - ay) * ux; LG.psi = Math.atan2(ux, uy);  return LG;}/** 次の区間へ移る手前の距離: 舵角 20° で回る円で角を回る分と、舵が効き始めるまでの遅れ(0.5·U·T)の分だけ早めに切り替える */function switchAt(R: number[], k: number, t: ShipType) {  const i = 2 * k, a1 = Math.atan2(R[i + 2] - R[i], R[i + 3] - R[i + 1]), a2 = Math.atan2(R[i + 4] - R[i + 2], R[i + 5] - R[i + 3]);  const turn = Math.min(Math.abs(wrapAngle(a2 - a1)), 2.6), Rt = t.L / steadyRp(t, 20 * DEG);  const len = Math.hypot(R[i + 2] - R[i], R[i + 3] - R[i + 1]);  return clamp(Rt * Math.tan(turn / 2) + 0.5 * speedOf(t) * t.T, t.L, Math.max(t.L, Math.min(12 * t.L, 0.6 * len)));}/** いまの区間を決める(終わりに近づいたら次の区間へ)。最後の点に着いたら done */export function advance(R: number[], nav: Nav, b: Hull) {  const n = R.length / 2;  nav.k = clamp(nav.k, 0, n - 2);  while (nav.k < n - 2) { const g = leg(R, nav.k, b.x, b.y); if (g.len - g.s < switchAt(R, nav.k, b.t)) nav.k++; else break; }  const g = leg(R, nav.k, b.x, b.y);  if (nav.k === n - 2 && g.s > g.len - Math.max(b.t.L, 60)) nav.done = true;}/** 舵を命じる。制御ごとに「向けたい針路 ψd」と「舵角 δ」の決め方が違う */export function control(ctl: Ctl, b: Hull, nav: Nav, R: number[], g: Gains, net: ArrayLike<number> | null, w: Env, tm: number, h: number, hand: number) {  const t = b.t, L = t.L, U = speedOf(t);  const lg = leg(R, nav.k, b.x, b.y);  nav.e = lg.e; nav.s = lg.s; nav.psiP = lg.psi;  // 羅針儀で測る向きと回頭の速さには、波の揺れが重なる  const wave = waveAt(w, tm, L), psi = b.psi + wave[0], r = b.r + wave[1];  nav.dpsi = wrapAngle(psi - lg.psi);  if (ctl === "hand") { b.cmd = hand; nav.psiD = psi; return; }  if (ctl === "ai") { b.cmd = net ? brain(net, lg.e, nav.dpsi, r) : 0; nav.psiD = psi; return; }  // 向けたい針路(LOS): 航路の上で Δ 先の点を狙う。積分つき LOS は、ずれ e を足し続けた量 yi のぶん、狙う点を横へずらす  // yi の増え方は参考文献の式に速さ U を掛けた形で、yi は長さ(m)になる。σ = 0.25 は、参考文献の σ(m/秒)では 0.25·U にあたる  const D = LOOK * L;  if (ctl === "ilos") nav.yi += (h * U * D * lg.e) / (D * D + (lg.e + SIGMA * nav.yi) ** 2);  const off = lg.e + (ctl === "ilos" ? SIGMA * nav.yi : 0), psiD = lg.psi - Math.atan(off / D);  nav.psiD = psiD; nav.aim = Math.hypot(D, off);  // 舵角 = Kp·(針路のずれ ε) + Ki·(ε を足し続けた量) − Kd·(回頭の速さ r)  const ep = wrapAngle(psiD - psi);  let d = g.kp * ep;  if (ctl !== "p") d -= g.kd * r;  if (ctl !== "p" && ctl !== "pd") {    if (g.ki > 0) {      nav.I += ep * h;      const m = g.lim / g.ki; // 積分の上限: 積分で足す舵角が lim を超えないように、足した量そのものを止める(ワインドアップ対策)      nav.I = clamp(nav.I, -m, m);      d += g.ki * nav.I;    } else nav.I = 0;  }  b.cmd = clamp(d, -RUD, RUD);}

controlは本文の式をそのまま計算する。向けたい針路はで、は積分つきLOSだけが使う積分である。の増え方は、ずれが大きいほど増え方が鈍る形である。参考文献5の式はで、ここでは速さを掛けて、を長さ(m)にそろえた。は0.25にしたので、参考文献5の(m/秒)に直すと、貨物船で約1.8 m/秒にあたる。

舵角はである。積分の上限は、足した量そのものをで止めて決める。こうすると、積分で足す舵角が上限を超えない。

Pだけだと蛇行が収まらない条件は、横流れと舵取機の遅れを無視して線形にした式から出る。船首方位を、航路の向きを0とするとで、舵はになる。野本の式に入れると、特性方程式が出る。ラウスの方法で調べると、根がすべて左半面に入る条件はで、によらない。

描かずに最後まで走らせる

runEpisodeは、画面に描かずに1回の航海を最後まで計算する。中身は画面の船と同じadvance・control・stepShipで、128刻みごとに一休みするジェネレーター関数にしてある。

走らせて測る(src/sims/ship-autopilot.ts の 209〜254 行)
export interface Episode { t: ShipType; route: number[]; x: number; y: number; psi: number; env: Env; dur: number; h: number }/** 測った量: ずれの 2 乗の和・刻みの数・舵が動いた角度の和・舵角の 2 乗の和・時間・座礁・大きく外れた・着いた */export interface Acc { e2: number; n: number; travel: number; d2: number; time: number; ground: boolean; lost: boolean; done: boolean }export const newAcc = (): Acc => ({ e2: 0, n: 0, travel: 0, d2: 0, time: 0, ground: false, lost: false, done: false });/** 1 回の航海を、画面に描かずに最後まで計算する。128 刻みごとに一休み(yield)して、1 コマの計算の予算を守る */export function* runEpisode(ep: Episode, ctl: Ctl, g: Gains, net: ArrayLike<number> | null, acc: Acc): Generator<void, void, void> {  const b: Hull = { t: ep.t, x: ep.x, y: ep.y, psi: ep.psi, r: 0, v: 0, delta: 0, cmd: 0 }, nav = newNav(), L = ep.t.L;  const n = Math.ceil(ep.dur / ep.h);  for (let i = 0; i < n; i++) {    const tm = i * ep.h;    advance(ep.route, nav, b);    control(ctl, b, nav, ep.route, g, net, ep.env, tm, ep.h, 0);    const d0 = b.delta;    stepShip(b, ep.h, ep.env, tm);    acc.e2 += nav.e * nav.e; acc.d2 += (b.delta / RUD) ** 2; acc.travel += Math.abs(b.delta - d0); acc.n++; acc.time += ep.h;    if (ep.env.chan && aground(ep.env.chan, b)) { acc.ground = true; break; }    if (Math.abs(nav.e) > 25 * L) { acc.lost = true; break; }    if (nav.done) { acc.done = true; break; }    if ((i & 127) === 127) yield;  }}/** 成績表に並べる制御(手で舵は除く) */export const BOARD: Ctl[] = ["p", "pd", "pid", "ilos", "ai"];interface Row { ctl: Ctl; rms: number; mv: number; st: "wait" | "ok" | "ground" | "lost"; ver: number; aiVer: number }interface Board { rows: Row[]; ver: number; aiVer: number; job: Generator<void, void, void> | null; cur: number; jobVer: number; jobAi: number; acc: Acc }const newBoard = (): Board => ({ rows: BOARD.map((ctl) => ({ ctl, rms: 0, mv: 0, st: "wait", ver: -1, aiVer: -1 })), ver: 0, aiVer: 0, job: null, cur: -1, jobVer: -1, jobAi: -1, acc: newAcc() });/** 成績表: 同じ海・同じ航路・同じ風と波で、制御ごとに最後まで走らせる。条件が変わるたびに、古くなった行から計算し直す */function boardTick(api: Api, s: S, until: number) {  const B = s.board;  while (performance.now() < until) {    if (!B.job) {      const i = B.rows.findIndex((r) => r.ver !== B.ver || (r.ctl === "ai" && r.aiVer !== B.aiVer));      if (i < 0) return;      B.cur = i; B.jobVer = B.ver; B.jobAi = B.aiVer; B.acc = newAcc();      B.job = runEpisode(boardEpisode(s), B.rows[i].ctl, gainsOf(api), B.rows[i].ctl === "ai" ? Float64Array.from(s.brainW) : null, B.acc);    }    const done = B.job.next().done;    if (B.jobVer !== B.ver || (B.rows[B.cur].ctl === "ai" && B.jobAi !== B.aiVer)) { B.job = null; continue; }    if (done) {      const r = B.rows[B.cur], a = B.acc;      r.rms = Math.sqrt(a.e2 / Math.max(1, a.n)); r.mv = a.travel / DEG / Math.max(1e-6, a.time / 60);      r.st = a.ground ? "ground" : a.lost ? "lost" : "ok"; r.ver = B.jobVer; r.aiVer = B.jobAi;      B.job = null;    }  }}

boardTickは、1コマの予算(画質に応じて1〜3ミリ秒)が尽きるまで、古くなった行から成績表の航海を進める。つまみ・航路・潮・風が変わるたびにverを1つ進め、AIの重みが10世代ぶん育つたびにaiVerを進める。計算の途中で条件が変わったら、その航海は捨ててやり直す。刻みは(0.2〜1秒)と、画面の船より粗い。

AIを育てる

brainは、3つの入力をおよそ1の大きさにそろえてから、6個のニューロンのを通し、その重み付きの和をもう一度に通して35°を掛ける。出力が舵角の上限を超えることはない。

AIを育てる(src/sims/ship-autopilot.ts の 256〜338 行)
export const NIN = 3, NH = 6, NW = NIN * NH + NH + NH + 1; // 入力 3・中間 6・出力 1。重みは 31 個/** 小さなニューラルネット: 航路からのずれ e・針路のずれ・回頭の速さ r の 3 つから舵角を決める */export function brain(w: ArrayLike<number>, e: number, dpsi: number, r: number) {  const x0 = e / 100, x1 = dpsi / 0.5, x2 = r / (0.5 * DEG); // 入力の大きさをそろえる(100 m・0.5 rad・0.5 °/秒 を 1 に)  let o = w[NW - 1];  for (let j = 0; j < NH; j++) o += w[NIN * NH + NH + j] * Math.tanh(w[3 * j] * x0 + w[3 * j + 1] * x1 + w[3 * j + 2] * x2 + w[NIN * NH + j]);  return RUD * Math.tanh(o);}export const GMAX = 120, LAMBDA = 8;/** 育てる海: 3 つの航海(右へ 3·L ずれた所から / 針路が 35° ずれた所から、横からの潮と風の中で / 途中で左へ 60° 曲がる航路)。長さは船の長さ L と T で決める */export function trainEpisodes(t: ShipType, rnd: () => number): Episode[] {  const L = t.L, U = speedOf(t), dur = clamp(14 * t.T, 240, 2400), h = clamp(t.T / 50, 0.2, 2), far = U * dur * 1.3 + 20 * L;  const turn = 0.4 * far, s30 = Math.sin(30 * DEG), c30 = Math.cos(30 * DEG);  return [    { t, route: [0, 0, far, 0], x: 0, y: -3 * L, psi: 90 * DEG, env: makeEnv(rnd), dur, h },    { t, route: [0, 0, far, 0], x: 0, y: 0, psi: 55 * DEG, env: makeEnv(rnd, { cur: 1.2 * KN, curTo: 0, wind: 10, windFrom: 200 * DEG }), dur, h },    { t, route: [0, 0, turn, 0, turn + far * s30, far * c30], x: 0, y: L, psi: 90 * DEG, env: makeEnv(rnd, { cur: 0.8 * KN, curTo: 225 * DEG, wind: 8, windFrom: 120 * DEG }), dur, h },  ];}export interface Trainer {  t: ShipType; seed: number; gen: number; spread: number;  p: Float64Array; pf: number; // 親の重みと成績  w: Float64Array; f: number; rms: number; // これまででいちばん良い重み(画面の船はこれで舵をとる)と、その成績・育てる海でのずれ(m)  hist: number[]; restarts: number; eps: Episode[]; job: Generator<void, void, void>; done: boolean;}const gauss = (rnd: () => number) => Math.sqrt(-2 * Math.log(Math.max(rnd(), 1e-12))) * Math.cos(TAU * rnd());const G0: Gains = { kp: 0, ki: 0, kd: 0, lim: 0 };/** 重み w の成績: 3 つの航海の (e/L)² の平均 + 舵を大きく切った分 + 舵を動かし続けた分 + 大きく外れた罰。小さいほど良い */function* score(T: Trainer, w: ArrayLike<number>, o: { J: number; rms: number }): Generator<void, void, void> {  let J = 0, e2 = 0, n = 0;  for (const ep of T.eps) {    const a = newAcc();    yield* runEpisode(ep, "ai", G0, w, a);    const m = Math.max(1, a.n), L = ep.t.L;    J += a.e2 / m / (L * L) + 0.3 * (a.d2 / m) + 0.3 * (a.travel / (a.time * ep.t.rate * DEG)) + (a.lost || a.ground ? 40 : 0);    e2 += a.e2; n += a.n;  }  o.J = J / T.eps.length; o.rms = Math.sqrt(e2 / Math.max(1, n));}/** 最初の重み: 平均 0・幅 0.3 の乱数 */function seedWeights(w: Float64Array, rnd: () => number) { for (let i = 0; i < NW; i++) w[i] = 0.3 * gauss(rnd); }/** * 進化戦略(1+8): 親の重みに小さな乱数(幅 spread。積分つき LOS の σ とは別のもの)を足した子を 8 つ作って走らせ、親より良い子がいれば、いちばん良い子を次の親にする。 * 親より良い子が 2 割より多ければ乱数の幅を広げ、少なければ狭める(1/5 成功則)。15 世代良くならなければ乱数の幅を広げ直し、 * 25 世代たっても成績が 1 より悪ければ、別の乱数の重みから育て直す(これまでいちばん良い重みは残す) */function* evolve(T: Trainer, rnd: () => number): Generator<void, void, void> {  const o = { J: 0, rms: 0 }, kid = new Float64Array(NW), best = new Float64Array(NW);  const take = () => { if (T.pf < T.f) { T.w = Float64Array.from(T.p); T.f = T.pf; T.rms = o.rms; } };  yield* score(T, T.p, o);  T.pf = o.J; take(); T.hist.push(T.rms);  let still = 0, since = 0;  while (T.gen < GMAX) {    let bf = Infinity, brms = 0, wins = 0;    for (let c = 0; c < LAMBDA; c++) {      for (let i = 0; i < NW; i++) kid[i] = T.p[i] + T.spread * gauss(rnd);      yield* score(T, kid, o);      if (o.J < T.pf) wins++;      if (o.J < bf) { bf = o.J; brms = o.rms; best.set(kid); }    }    if (bf < T.pf) { T.p.set(best); T.pf = bf; o.rms = brms; take(); still = 0; } else still++;    T.spread = clamp(T.spread * (wins / LAMBDA > 0.2 ? 1.2 : 0.93), 0.05, 2);    if (still >= 15) { T.spread = Math.max(T.spread, 0.4); still = 0; }    T.gen++; since++;    if (since >= 25 && T.pf > 1) { seedWeights(T.p, rnd); yield* score(T, T.p, o); T.pf = o.J; take(); T.spread = 0.5; since = 0; still = 0; T.restarts++; }    T.hist.push(T.rms);  }  T.done = true;}/** 育て直す: 種 seed の乱数で、最初の重みを決めてから育て始める */export function makeTrainer(t: ShipType, rnd: () => number, seed: number): Trainer {  const eps = trainEpisodes(t, rnd), p = new Float64Array(NW);  seedWeights(p, rnd);  const T: Trainer = { t, seed, gen: 0, spread: 0.5, p, pf: Infinity, w: Float64Array.from(p), f: Infinity, rms: NaN, hist: [], restarts: 0, eps, job: null as unknown as Generator<void, void, void>, done: false };  T.job = evolve(T, rnd);  return T;}/** 開いたときに使う重み: 上の育て方で、貨物船を種 1 から 120 世代育てた結果を、小数 6 桁に丸めて書き写したもの(hist は 10 世代ごとの、育てる海でのずれ・m) */export const PRE = {  ship: "cargo", gen: 120,  w: [0.373581, 1.672428, 0.47169, -2.592847, 0.9112, 0.592899, 2.171151, -1.179027, 0.942748, 1.991609, 3.647152, 0.964765, -1.173419, -0.945643, 2.491063, -0.599144, 1.437723, -1.335759, 0.921131, 0.607654, 1.604637, 0.517095, -2.421152, -2.083213, -1.647688, 2.31064, 0.709096, -2.33937, -0.706751, 0.163656, 0.308923],  hist: [333.3, 96.5, 70.1, 65.8, 65.6, 65.1, 65.1, 65.1, 65.1, 65.1, 65.1, 65.1, 65.1],};

成績の点数は、3つの航海それぞれの「の平均 + 0.3×(舵角/35°)²の平均 + 0.3×舵取機が全速で動いた時間の割合」の平均で、大きく外れたら40を足す。evolveは(1+8)の進化戦略で、子の重みは親の重みに、幅の正規分布の乱数を足して作る。8つの子のうち親を上回った子が2割より多ければを1.2倍に広げ、少なければ0.93倍に狭める(1/5成功則)。15世代続けて良くならなければを広げ直す。25世代たっても点数が1より悪いときは、別の乱数の重みから育て直す。ずれだけを見て舵を切る重みは大きな船を蛇行させ、点数の谷に落ちたまま抜けにくいためとみられる。

PREの31個の数は、同じプログラムを画面の外で動かし、貨物船を種1から120世代育てた重みを、小数6桁に丸めて書き写したものだ。その計算に、サーバーで約3秒かかった。ブラウザの中では1コマに3ミリ秒ずつ進めるので、1秒に60コマ描ける端末で20秒ほどかかる。

描く

描く(src/sims/ship-autopilot.ts の 463〜757 行)
const toS = (c: Cam, x: number, y: number): Pt => (c.rot ? [c.cx - c.k * (y - c.wy), c.cy - c.k * (x - c.wx)] : [c.cx + c.k * (x - c.wx), c.cy - c.k * (y - c.wy)]);const toW = (c: Cam, px: number, py: number): Pt => (c.rot ? [c.wx - (py - c.cy) / c.k, c.wy - (px - c.cx) / c.k] : [c.wx + (px - c.cx) / c.k, c.wy - (py - c.cy) / c.k]);/** 画面の割り付け: 横長なら下に計器の帯(舵角・グラフ・成績表)、縦長なら計器を 2 段にする */function layoutOf(api: Api): Lay {  const W = api.w, Hh = api.h, m = api.u(10), gap = api.u(8), top = api.u(34);  if (W >= Hh * 1.1 && W >= 560) {    const ph = api.u(128), py = Hh - m - ph, dw = api.u(84), bw = Math.min(api.u(250), W * 0.34);    return { narrow: false, map: { x: m, y: top, w: W - 2 * m, h: Math.max(40, py - gap - top) }, dial: { x: m, y: py, w: dw, h: ph }, graph: { x: m + dw + gap, y: py, w: W - 2 * m - dw - bw - 2 * gap, h: ph }, board: { x: W - m - bw, y: py, w: bw, h: ph } };  }  const r1 = api.u(104), r2 = api.u(100), py = Hh - m - r2 - gap - r1, dw = api.u(84);  return { narrow: true, map: { x: m, y: top, w: W - 2 * m, h: Math.max(40, py - gap - top) }, dial: { x: m, y: py, w: dw, h: r1 }, graph: { x: m + dw + gap, y: py, w: W - 2 * m - dw - gap, h: r1 }, board: { x: m, y: py + r1 + gap, w: W - 2 * m, h: r2 } };}/** 海図の縮尺と向き: 場面の範囲が地図の枠にいちばん大きく収まるようにする。縦長の枠では東を上にして回す */function camera(s: S, M: Rect, pad: number): Cam {  const b = s.box, W = Math.max(20, M.w - 2 * pad), Hh = Math.max(20, M.h - 2 * pad);  const k0 = Math.min(W / b.A, Hh / b.C), k1 = Math.min(W / b.C, Hh / b.A), rot = k1 > k0 * 1.15;  return { k: rot ? k1 : k0, rot, cx: M.x + M.w / 2, cy: M.y + M.h / 2, wx: b.cx, wy: b.cy };}const fmtTime = (t: number) => { const m = Math.floor(t / 60), sec = Math.floor(t % 60); return m ? `${m}分${String(sec).padStart(2, "0")}秒` : `${sec}秒`; };const rudTxt = (d: number) => { const v = Math.round(d / DEG); return v === 0 ? "0°" : `${v > 0 ? "右" : "左"} ${Math.abs(v)}°`; };const sideTxt = (e: number) => (Math.abs(e) < 0.5 ? "0 m" : `${e > 0 ? "右" : "左"} ${fmt(Math.abs(e), Math.abs(e) < 10 ? 1 : 0)} m`);const angTxt = (a: number) => { const v = a / DEG; return Math.abs(v) < 0.05 ? "0°" : `${v > 0 ? "右" : "左"} ${fmt(Math.abs(v), 1)}°`; };const distTxt = (m: number) => (m >= 1000 ? `${fmt(m / 1000, m >= 10000 ? 0 : 1)} km` : `${fmt(m, 0)} m`);function niceStep(m: number) { for (const v of [10, 20, 50, 100, 200, 250, 500, 1000, 2000, 5000]) if (v >= m) return v; return 10000; }const niceMax = (v: number, steps: number[]) => { for (const x of steps) if (v <= x) return x; return steps[steps.length - 1]; };const CTL_TXT: Record<Ctl, string> = { hand: "手で舵", p: "P(比例だけ)", pd: "PD(比例と微分)", pid: "PID", ilos: "積分つき LOS と PID", ai: "AI(小さなニューラルネット)" };const ROW_TXT: Record<Ctl, string> = { hand: "手", p: "P", pd: "PD", pid: "PID", ilos: "積分LOS", ai: "AI" };const HULL: Pt[] = [[1, 0], [0.86, 0.55], [0.6, 0.95], [0.3, 1], [-0.86, 1], [-1, 0.78], [-1, -0.78], [-0.86, -1], [0.3, -1], [0.6, -0.95], [0.86, -0.55]]; /** 海図: 格子の点線・白波・水道の岸・潮の粒・航路・航跡・狙う点・船 */function drawMap(api: Api, s: S, c: Cam, M: Rect) {  const ctx = api.ctx, b = s.ship, env = s.env, ctl = ctlOf(api);  ctx.save(); ctx.beginPath(); ctx.rect(M.x, M.y, M.w, M.h); ctx.clip();  // 格子(東西・南北の点線)  const step = niceStep(api.u(95) / c.k), cs = [toW(c, M.x, M.y), toW(c, M.x + M.w, M.y), toW(c, M.x, M.y + M.h), toW(c, M.x + M.w, M.y + M.h)];  const xs = cs.map((p) => p[0]), ys = cs.map((p) => p[1]), gx0 = Math.min(...xs), gx1 = Math.max(...xs), gy0 = Math.min(...ys), gy1 = Math.max(...ys);  const go = { w: api.u(0.6), dash: [api.u(1), api.u(6)] };  for (let x = Math.ceil(gx0 / step) * step; x <= gx1; x += step) { const [a, d] = toS(c, x, gy0), [p, q] = toS(c, x, gy1); api.line(a, d, p, q, go); }  for (let y = Math.ceil(gy0 / step) * step; y <= gy1; y += step) { const [a, d] = toS(c, gx0, y), [p, q] = toS(c, gx1, y); api.line(a, d, p, q, go); }  // 白波: 風が強いほど多い(風下へ向いた小さな山形)  if (env.wind >= 8) {    const n = Math.min(80, Math.round((env.wind - 5) ** 2 * 0.6)), to = env.windFrom + Math.PI, q = api.u(3.2);    const [A0, B0] = toS(c, 0, 0), [A1, B1] = toS(c, Math.sin(to), Math.cos(to)), l = Math.hypot(A1 - A0, B1 - B0) || 1, ux = (A1 - A0) / l, uy = (B1 - B0) / l;    ctx.beginPath();    for (let i = 0; i < n; i++) {      const hx = Math.sin(i * 12.9898) * 43758.5453, hy = Math.sin(i * 78.233) * 12345.678;      const [X, Y] = toS(c, s.box.cx + (hx - Math.floor(hx) - 0.5) * s.box.A, s.box.cy + (hy - Math.floor(hy) - 0.5) * s.box.C);      ctx.moveTo(X - ux * q - uy * q, Y - uy * q + ux * q); ctx.lineTo(X, Y); ctx.lineTo(X - ux * q + uy * q, Y - uy * q - ux * q);    }    ctx.lineWidth = api.u(0.8); ctx.strokeStyle = api.ink; ctx.setLineDash([]); ctx.stroke();  }  // 水道の岸(斜線が陸)。斜線は重いので、縮尺や色が変わったときだけ別の描画面に描き直し、毎コマはそれを写す  const land = landLayer(api, s, c, M);  if (land) ctx.drawImage(land, 0, 0, api.w, api.h);  // 潮の粒: 流れの向きに短い線を引く(長さは 2 分間に流される距離)  if (env.cur > 0) {    const P = s.parts;    ctx.beginPath();    for (let i = 0; i < s.pN; i++) {      const cv = curAt(env, P[3 * i], P[3 * i + 1]);      if (Math.hypot(cv[0], cv[1]) < 0.03) continue;      const [X0, Y0] = toS(c, P[3 * i], P[3 * i + 1]), [X1, Y1] = toS(c, P[3 * i] - cv[0] * 120, P[3 * i + 1] - cv[1] * 120);      ctx.moveTo(X0, Y0); ctx.lineTo(X1, Y1);    }    ctx.lineWidth = api.u(1); ctx.strokeStyle = api.ink; ctx.setLineDash([]); ctx.stroke();    // 流れていく側の端に小さな点(流れの向きが分かるように)    ctx.beginPath();    const q = api.u(1.3);    for (let i = 0; i < s.pN; i++) { const cv = curAt(env, P[3 * i], P[3 * i + 1]); if (Math.hypot(cv[0], cv[1]) < 0.03) continue; const [X0, Y0] = toS(c, P[3 * i], P[3 * i + 1]); ctx.rect(X0 - q, Y0 - q, 2 * q, 2 * q); }    ctx.fillStyle = api.ink; ctx.fill();  }  // 航路: 全体は破線、いまの区間は実線。点は四角の取っ手  const R = s.route, n = R.length / 2, pts: Pt[] = [];  for (let i = 0; i < n; i++) pts.push(toS(c, R[2 * i], R[2 * i + 1]));  api.poly(pts, { w: api.u(1.3), dash: [api.u(8), api.u(5)] });  const k = s.nav.k;  api.line(pts[k][0], pts[k][1], pts[k + 1][0], pts[k + 1][1], { w: api.u(2.2) });  // 航跡  if (s.trN > 1) {    ctx.beginPath();    for (let j = 0; j < s.trN; j++) { const i = (s.trHead - s.trN + j + TRN) % TRN, [X, Y] = toS(c, s.tr[2 * i], s.tr[2 * i + 1]); if (j) ctx.lineTo(X, Y); else ctx.moveTo(X, Y); }    const [X, Y] = toS(c, b.x, b.y); ctx.lineTo(X, Y);    ctx.lineWidth = api.u(1.1); ctx.strokeStyle = api.ink; ctx.setLineDash([]); ctx.stroke();  }  const hs = api.u(5.5);  pts.forEach(([X, Y], i) => {    api.rect(X - hs, Y - hs, 2 * hs, 2 * hs, { fill: "paper", w: api.u(1.4) });    if (i === n - 1) api.rect(X - hs * 1.6, Y - hs * 1.6, 3.2 * hs, 3.2 * hs, { w: api.u(1) });  });  api.label("出発", pts[0][0] + api.u(10), pts[0][1] + api.u(18), { size: api.u(10.5) });  api.label("到着", pts[n - 1][0] - api.u(10), pts[n - 1][1] + api.u(22), { size: api.u(10.5), align: "right" });  // 狙う点(LOS): 船から向けたい針路へ、点線と小さな丸  if (ctl !== "hand" && ctl !== "ai") {    const ax = b.x + Math.sin(s.nav.psiD) * s.nav.aim, ay = b.y + Math.cos(s.nav.psiD) * s.nav.aim, [X0, Y0] = toS(c, b.x, b.y), [X1, Y1] = toS(c, ax, ay);    api.line(X0, Y0, X1, Y1, { w: api.u(1.1), dash: [api.u(1.5), api.u(3.5)] });    api.circle(X1, Y1, api.u(4), { fill: "paper", w: api.u(1.2) });    api.label("狙う点", X1 + api.u(7), Y1 - api.u(7), { size: api.u(10) });  }  // 座礁した所  for (let i = 0; i + 1 < s.marks.length; i += 2) { const [X, Y] = toS(c, s.marks[i], s.marks[i + 1]), q = api.u(6); api.line(X - q, Y - q, X + q, Y + q, { w: api.u(2) }); api.line(X - q, Y + q, X + q, Y - q, { w: api.u(2) }); }  // 突風: 吹いてくる向きから、船へ向かう 3 本の矢印  const G = env.gust;  if (G && s.time > G.t0 && s.time < G.t0 + G.dur) {    const to = G.from + Math.PI, [A0, B0] = toS(c, b.x, b.y), [A1, B1] = toS(c, b.x + Math.sin(to), b.y + Math.cos(to)), l = Math.hypot(A1 - A0, B1 - B0) || 1, ux = (A1 - A0) / l, uy = (B1 - B0) / l;    const back = api.u(70), len = api.u(30);    for (const o of [-1, 0, 1]) { const x0 = A0 - ux * back - uy * o * api.u(16), y0 = B0 - uy * back + ux * o * api.u(16); api.arrow(x0, y0, x0 + ux * len, y0 + uy * len, { w: api.u(1.6), head: api.u(7) }); }    api.label("突風", A0 - ux * back + api.u(10), B0 - uy * back - api.u(18), { size: api.u(10.5) });  }  drawShip(api, c, b, b.psi + waveAt(env, s.time, b.t.L)[0]);  // 縮尺  const lx = M.x + api.u(12), ly = M.y + M.h - api.u(12), lw = step * c.k;  api.line(lx, ly, lx + lw, ly, { w: api.u(1.4) });  api.line(lx, ly - api.u(4), lx, ly + api.u(4), { w: api.u(1.2) }); api.line(lx + lw, ly - api.u(4), lx + lw, ly + api.u(4), { w: api.u(1.2) });  api.label(distTxt(step), lx + lw + api.u(8), ly + api.u(4), { size: api.u(11), font: "mono" });  ctx.restore();  api.rect(M.x, M.y, M.w, M.h, { w: api.u(1) });}/** 水道の陸(斜線)と岸の線を、画面と同じ大きさの別の描画面に描く */function landLayer(api: Api, s: S, c: Cam, M: Rect) {  const ch = s.env.chan, doc = (globalThis as { document?: Document }).document;  if (!ch || !doc) return null;  const dpr = api.ctx.canvas.width / Math.max(1, api.w);  const key = [c.k, c.rot, c.cx, c.cy, c.wx, c.wy, api.w, api.h, api.ink, dpr, M.x, M.y, M.w, M.h].join("|");  if (s.land.cv && s.land.key === key) return s.land.cv;  const cv = s.land.cv ?? doc.createElement("canvas");  cv.width = Math.max(1, Math.round(api.w * dpr)); cv.height = Math.max(1, Math.round(api.h * dpr));  const g = cv.getContext("2d");  if (!g) return null;  g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, api.w, api.h);  g.save(); g.beginPath(); g.rect(M.x, M.y, M.w, M.h); g.clip();  g.strokeStyle = api.ink; g.setLineDash([]);  const xa = Math.max(ch.x0, s.box.cx - s.box.A / 2 - 400), xb = Math.min(ch.x1, s.box.cx + s.box.A / 2 + 400), gap = api.u(7);  for (const sd of [1, -1]) {    const coast: Pt[] = [];    for (let x = xa; x <= xb + 1; x += 50) coast.push(toS(c, x, chanY(ch, x) + sd * ch.hw));    const far = sd * (ch.amp + ch.hw + s.box.C);    g.save(); g.beginPath();    coast.forEach(([X, Y], i) => (i ? g.lineTo(X, Y) : g.moveTo(X, Y)));    for (const [X, Y] of [toS(c, xb, far), toS(c, xa, far)]) g.lineTo(X, Y);    g.closePath(); g.clip();    g.beginPath();    for (let d = -M.h; d <= M.w; d += gap * Math.SQRT2) { g.moveTo(M.x + d, M.y); g.lineTo(M.x + d + M.h, M.y + M.h); }    g.lineWidth = api.u(0.6); g.stroke(); g.restore();    g.beginPath(); coast.forEach(([X, Y], i) => (i ? g.lineTo(X, Y) : g.moveTo(X, Y)));    g.lineWidth = api.u(1.8); g.stroke();  }  g.restore();  s.land = { key, cv };  return cv;}/** 船: 画面の上で小さすぎる船は、決まった大きさの記号で描く。舵は船尾から後ろへ(右舵なら後ろの端が右へ振れる) */function drawShip(api: Api, c: Cam, b: Hull, psi: number) {  const t = b.t, sx = Math.sin(psi), sy = Math.cos(psi);  const sc = Math.max(1, api.u(16) / (t.L * c.k)), hl = (t.L / 2) * sc, hb = Math.max((t.B / 2) * sc, api.u(2.8) / c.k);  const P = (a: number, q: number) => toS(c, b.x + sx * a * hl + sy * q * hb, b.y + sy * a * hl - sx * q * hb);  const rl = Math.max(0.2 * hl, api.u(5) / c.k), dl = b.delta;  const [r0x, r0y] = P(-1, 0), [r1x, r1y] = toS(c, b.x - sx * hl + (-sx * Math.cos(dl) + sy * Math.sin(dl)) * rl, b.y - sy * hl + (-sy * Math.cos(dl) - sx * Math.sin(dl)) * rl);  api.line(r0x, r0y, r1x, r1y, { w: api.u(1.8) });  api.poly(HULL.map(([a, q]) => P(a, q)), { close: true, fill: "ink", stroke: "paper", w: api.u(1.6) });  api.poly([P(-0.66, 0.6), P(-0.44, 0.6), P(-0.44, -0.6), P(-0.66, -0.6)], { close: true, fill: "paper", stroke: false });}const RMAX = { wind: 25, cur: 4 * KN };/** 右上の輪(潮と風)の中心と半径 */function ringAt(api: Api, which: "wind" | "cur"): [number, number, number] {  const R = api.u(19), y = api.u(34) + api.u(22) + R, xw = api.w - api.u(22) - R; // 地図の枠の右上の内側  return which === "wind" ? [xw, y, R] : [xw - 2 * R - api.u(36), y, R];}/** 世界の方位 a(北から右回り)を、画面の上の向き(長さ 1)に直す */function screenDir(c: Cam, a: number): Pt { return c.rot ? [-Math.cos(a), -Math.sin(a)] : [Math.sin(a), -Math.cos(a)]; }function drawRing(api: Api, s: S, c: Cam, which: "wind" | "cur") {  const [X, Y, R] = ringAt(api, which), env = s.env;  api.circle(X, Y, R, { fill: "paper", w: api.u(0.9), dash: [api.u(2), api.u(3)] });  const [nx, ny] = screenDir(c, 0);  api.line(X + nx * R, Y + ny * R, X + nx * (R + api.u(5)), Y + ny * (R + api.u(5)), { w: api.u(1.2) });  if (which === "cur") api.text("N", X + nx * (R + api.u(11)), Y + ny * (R + api.u(11)) + api.u(3.5), { size: api.u(9.5), align: "center", weight: 600 });  const V = which === "wind" ? env.wind : env.cur, to = which === "wind" ? env.windFrom + Math.PI : env.curTo;  if (V > 0) {    const [dx, dy] = screenDir(c, to), q = R * (0.3 + 0.62 * Math.min(1, V / RMAX[which]));    api.arrow(X - dx * q * 0.55, Y - dy * q * 0.55, X + dx * q, Y + dy * q, { w: api.u(2), head: api.u(7) });    const hs = api.u(3); api.rect(X + dx * q - hs, Y + dy * q - hs, 2 * hs, 2 * hs, { fill: "paper", w: api.u(1) });  } else api.dot(X, Y, api.u(2.5));  const G = env.gust, gust = which === "wind" && G && s.time > G.t0 && s.time < G.t0 + G.dur;  const txt = which === "wind" ? (V > 0 ? `風 ${fmt(V, 0)} m/秒` : "風 なし") : V > 0 ? `潮 ${fmt(V / KN, 1)} ノット` : "潮 なし";  api.label(txt, X, Y + R + api.u(14), { size: api.u(10), align: "center", font: "mono" });  if (gust) api.label("突風", X, Y + R + api.u(29), { size: api.u(10), align: "center" });}/** 舵角指示器: 船を上から見て、舵が後ろへ伸びる向き。太い針がいまの舵角、三角が命じた舵角 */function dialGeo(api: Api, D: Rect) { const R = Math.min(D.w * 0.36, D.h * 0.4); return { X: D.x + D.w / 2, Y: D.y + api.u(14), R }; }function drawDial(api: Api, s: S, D: Rect) {  const b = s.ship, { X, Y, R } = dialGeo(api, D);  api.rect(D.x, D.y, D.w, D.h, { fill: "paper", w: api.u(1) });  const arc: Pt[] = [];  for (let i = 0; i <= 20; i++) { const d = (-35 + (70 * i) / 20) * DEG; arc.push([X + Math.sin(d) * R, Y + Math.cos(d) * R]); }  api.poly(arc, { w: api.u(1) });  for (let v = -35; v <= 35; v += 5) { const d = v * DEG, q = v % 10 === 0 || Math.abs(v) === 35 ? api.u(5) : api.u(2.5); api.line(X + Math.sin(d) * R, Y + Math.cos(d) * R, X + Math.sin(d) * (R + q), Y + Math.cos(d) * (R + q), { w: api.u(0.9) }); }  api.text("左", X - R * 0.62 - api.u(5), Y + R * 0.95 + api.u(4), { size: api.u(10), align: "right" });  api.text("右", X + R * 0.62 + api.u(5), Y + R * 0.95 + api.u(4), { size: api.u(10) });  api.line(X, Y, X + Math.sin(b.delta) * R * 0.95, Y + Math.cos(b.delta) * R * 0.95, { w: api.u(2.4) });  api.dot(X, Y, api.u(2.6));  const cm = b.cmd, tq = R + api.u(7);  api.poly([[X + Math.sin(cm) * tq, Y + Math.cos(cm) * tq], [X + Math.sin(cm - 0.08) * (tq + api.u(6)), Y + Math.cos(cm - 0.08) * (tq + api.u(6))], [X + Math.sin(cm + 0.08) * (tq + api.u(6)), Y + Math.cos(cm + 0.08) * (tq + api.u(6))]], { close: true, fill: "ink", stroke: false });  api.text(`舵角 ${rudTxt(b.delta)}`, X, D.y + D.h - api.u(22), { size: api.u(10.5), align: "center", font: "mono" });  api.text(ctlOf(api) === "hand" ? "手で舵" : "触ると手で舵", X, D.y + D.h - api.u(8), { size: api.u(9.5), align: "center" });}/** 時間のグラフ: 航路からのずれ e・針路のずれ・舵角 δ(右が上) */function drawGraphs(api: Api, s: S, G: Rect) {  api.rect(G.x, G.y, G.w, G.h, { fill: "paper", w: api.u(1) });  const n = s.histN, ctx = api.ctx, x0 = G.x + api.u(70), x1 = G.x + G.w - api.u(8), top = G.y + api.u(6), rh = (G.h - api.u(22)) / 3;  let m0 = 0, m1 = 0;  for (let j = 0; j < n; j++) { const i = ((s.histHead - n + j + HN) % HN) * 3; m0 = Math.max(m0, Math.abs(s.hist[i])); m1 = Math.max(m1, Math.abs(s.hist[i + 1])); }  const rows = [    { name: "ずれ e", max: niceMax(m0, [10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]), unit: "m" },    { name: "針路のずれ", max: niceMax(m1, [2, 5, 10, 20, 45, 90, 180]), unit: "°" },    { name: "舵角 δ", max: 35, unit: "°" },  ];  rows.forEach((rw, k) => {    const yc = top + rh * (k + 0.5), hh = rh * 0.42;    api.line(x0, yc, x1, yc, { w: api.u(0.6), dash: [api.u(1), api.u(3)] });    api.line(x0, yc - hh, x0, yc + hh, { w: api.u(0.6) });    api.text(rw.name, G.x + api.u(8), yc - api.u(1), { size: api.u(10.5) });    api.text(`±${rw.max} ${rw.unit}`, G.x + api.u(8), yc + api.u(11), { size: api.u(9.5), font: "mono" });    if (n < 2) return;    ctx.beginPath();    for (let j = 0; j < n; j++) {      const i = ((s.histHead - n + j + HN) % HN) * 3, X = x1 - (x1 - x0) * ((n - 1 - j) / (HN - 1)), Y = yc - clamp(s.hist[i + k] / rw.max, -1.1, 1.1) * hh;      if (j) ctx.lineTo(X, Y); else ctx.moveTo(X, Y);    }    ctx.lineWidth = api.u(k === 0 ? 1.8 : 1.2); ctx.strokeStyle = api.ink; ctx.setLineDash([]); ctx.stroke();  });  api.text(`過去 ${fmtTime(s.histWin)}  上が右`, x1, G.y + G.h - api.u(6), { size: api.u(9.5), align: "right", font: "mono" });}/** 成績表: 同じ海で最後まで走らせた、ずれの 2 乗平均と舵の動き(1 分あたりに舵が動いた角度) */function drawBoard(api: Api, s: S, Bd: Rect) {  api.rect(Bd.x, Bd.y, Bd.w, Bd.h, { fill: "paper", w: api.u(1) });  const rows = s.board.rows, ctl = ctlOf(api), nameW = api.u(62), colW = (Bd.w - nameW - api.u(14)) / 2, c1 = Bd.x + nameW, c2 = c1 + colW + api.u(6);  const head = api.u(20), rh = (Bd.h - head - api.u(4)) / rows.length;  api.text("同じ海を走らせると", Bd.x + api.u(8), Bd.y + api.u(14), { size: api.u(10) });  api.text("ずれ", c1 + colW, Bd.y + api.u(14), { size: api.u(10), align: "right" });  api.text("舵の動き", c2 + colW, Bd.y + api.u(14), { size: api.u(10), align: "right" });  let mr = 1, mm = 1;  for (const r of rows) if (r.st === "ok" && r.ver === s.board.ver) { mr = Math.max(mr, r.rms); mm = Math.max(mm, r.mv); }  rows.forEach((r, i) => {    const y = Bd.y + head + rh * (i + 0.5), fresh = r.ver === s.board.ver && (r.ctl !== "ai" || r.aiVer === s.board.aiVer);    if (r.ctl === ctl) { const q = api.u(3.5), x = Bd.x + api.u(7); api.poly([[x, y - q], [x + q * 1.4, y], [x, y + q]], { close: true, fill: "ink", stroke: false }); }    api.text(ROW_TXT[r.ctl], Bd.x + api.u(15), y + api.u(4), { size: api.u(10.5), weight: r.ctl === ctl ? 700 : 400 });    if (r.ver < 0 || (!fresh && r.st === "wait")) { api.text("計算中", c1, y + api.u(4), { size: api.u(10) }); return; }    if (r.st !== "ok") { api.text(r.st === "ground" ? "座礁" : "外れた", c1, y + api.u(4), { size: api.u(10.5), weight: 700 }); }    else {      const bh = api.u(5), tw = api.u(38);      api.rect(c1, y - bh / 2, Math.max(api.u(1.5), ((colW - tw) * r.rms) / mr), bh, { fill: "ink", stroke: false });      api.text(`${fmt(r.rms, 0)} m`, c1 + colW, y + api.u(4), { size: api.u(10), align: "right", font: "mono" });    }    const bh = api.u(5), tw = api.u(46);    api.rect(c2, y - bh / 2, Math.max(api.u(1.5), ((colW - tw) * r.mv) / mm), bh, { w: api.u(1) });    api.text(`${fmt(r.mv, 0)}°/分`, c2 + colW, y + api.u(4), { size: api.u(10), align: "right", font: "mono" });    if (!fresh) api.text("…", c1 - api.u(4), y + api.u(4), { size: api.u(10), align: "right" });  });}/** AI の箱: 世代と、育てる海でのずれの移り変わり。広い画面では、ニューラルネットの重みも線の太さで描く(破線は負の重み) */function drawAi(api: Api, s: S, M: Rect) {  const x = M.x + api.u(8), y = M.y + api.u(8), w = Math.min(api.u(226), M.w * 0.62), h = api.u(104), T = s.trainer;  api.rect(x, y, w, h, { fill: "paper", w: api.u(1) });  const title = T ? `育てている ${s.brainGen} / ${GMAX} 世代` : s.brainPre ? `育て終えた重み(${PRE.gen} 世代)` : `育て終わり(${s.brainGen} 世代)`;  api.text(title, x + api.u(8), y + api.u(15), { size: api.u(10.5), weight: 600 });  api.text(`${SHIPS[s.brainShip]?.label ?? ""}で育てた`, x + api.u(8), y + api.u(29), { size: api.u(9.5) });  const net = w >= api.u(200), gx = x + (net ? api.u(104) : api.u(10)), gw = x + w - api.u(10) - gx, gy = y + api.u(40), gh = h - api.u(56);  if (net) {    const W = s.brainW, ix = x + api.u(18), hx = x + api.u(52), ox = x + api.u(84), yy = (i: number, n: number) => gy + (gh * (i + 0.5)) / n;    for (let j = 0; j < NH; j++) for (let i = 0; i < NIN; i++) { const v = W[3 * j + i]; api.line(ix, yy(i, NIN), hx, yy(j, NH), { w: api.u(0.3 + 1.4 * Math.min(1, Math.abs(v) / 3)), dash: v < 0 ? [api.u(2), api.u(2)] : [] }); }    for (let j = 0; j < NH; j++) { const v = W[NIN * NH + NH + j]; api.line(hx, yy(j, NH), ox, yy(0, 1), { w: api.u(0.3 + 1.4 * Math.min(1, Math.abs(v) / 3)), dash: v < 0 ? [api.u(2), api.u(2)] : [] }); }    ["e", "ψ", "r"].forEach((l, i) => { api.circle(ix, yy(i, NIN), api.u(3), { fill: "paper", w: api.u(1) }); api.text(l, ix - api.u(6), yy(i, NIN) + api.u(3.5), { size: api.u(10), align: "right", font: "math" }); });    for (let j = 0; j < NH; j++) api.circle(hx, yy(j, NH), api.u(2.6), { fill: "ink", stroke: false });    api.circle(ox, yy(0, 1), api.u(3.4), { fill: "paper", w: api.u(1) });    api.text("δ", ox + api.u(6), yy(0, 1) + api.u(3.5), { size: api.u(10), font: "math" });  }  const H = s.brainHist;  if (H.length > 1) {    let mx = 1; for (const v of H) if (Number.isFinite(v)) mx = Math.max(mx, v);    const top = niceMax(mx, [50, 100, 200, 500, 1000, 2000, 5000, 10000]);    api.spark(H.map((v) => (Number.isFinite(v) ? v : top)), gx, gy, gw, gh, { min: 0, max: top, w: api.u(1.4) });    api.text(`ずれ ${Number.isFinite(s.brainRms) ? fmt(s.brainRms, 0) : "-"} m`, gx + gw, gy + api.u(4), { size: api.u(10), align: "right", font: "mono" });    api.text(`0〜${top} m`, gx, gy + gh + api.u(11), { size: api.u(9), font: "mono" });    api.text("世代 →", gx + gw, gy + gh + api.u(11), { size: api.u(9), align: "right" });  }}function readout(api: Api, s: S) {  const b = s.ship, nav = s.nav, ctl = ctlOf(api), st = s.stat, t = b.t, env = s.env;  api.readout([    `制御  ${CTL_TXT[ctl]}    舵角  ${rudTxt(b.delta)}`,    `航路からのずれ e  ${sideTxt(nav.e)}    針路のずれ  ${angTxt(nav.dpsi)}`,    st.time > 60 ? `この走りの成績  ずれ ${fmt(Math.sqrt(st.e2 / Math.max(1, st.n)), 0)} m(2 乗平均)    舵の動き ${fmt(st.travel / DEG / (st.time / 60), 0)} °/分` : "この走りの成績  測っている途中",    `潮  ${env.cur > 0 ? `${fmt(env.cur / KN, 1)} ノット` : "なし"}    風  ${env.wind > 0 ? `${fmt(env.wind, 0)} m/秒` : "なし"}`,    ctl === "ai" ? `AI  ${SHIPS[s.brainShip]?.label ?? ""}で ${s.brainGen} 世代育てた    育てる海でのずれ  ${Number.isFinite(s.brainRms) ? fmt(s.brainRms, 0) : "-"} m` : `${t.label}  ${t.L} m  ${fmt(t.kn, 1)} ノット    K  ${fmt(t.K, 3)} /秒    T  ${fmt(t.T, 1)} 秒`,  ]);}

横長の画面では計器を下に1段、縦長の画面では2段に並べる。地図の枠が縦長なら、海図を回して東を上にする。潮の粒は、2分間に流される長さの線と、流れていく側の端の点で描く。白波は風下を向いた小さな山形で、風が強いほど増える。水道の陸の斜線は描くのが重いので、縮尺や色が変わったときだけ別の描画面に描き直し、毎コマはその絵を写す。成績表の棒は、いちばん大きい値を枠の幅にそろえた長さで、塗りの棒がずれ、輪郭だけの棒が舵の動きである。AIの箱の線は、重みの大きさを太さで、負の重みを破線で表す。

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

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

読み込んでいます

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

姉妹サイト

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