このシミュレーションは、1コマごとに、0.2秒の刻みで「いまの区間を決める → 舵を命じる → 船を進める」を何回かくり返す。1コマの計算の予算が余れば、その残りで成績表の航海とAIの育成を少しずつ進める。最後に海図と計器を描く。
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 ], };
描く(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の箱の線は、重みの大きさを太さで、負の重みを破線で表す。