このシミュレーションは、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; // 「狭い海峡」の場面の水路の幅(海里)
舵を切ってから曲がるまで
船首が回る速さrは、本文の式Tdr/dt+r=Kδで舵角δに遅れて追いつく。コードのsh.r += (h * (K * sh.delta - sh.r)) / Tがこの式を1秒ぶん進める行で、KとTはsteerが船の長さと速さから出す。長い船ほど、遅い船ほどTが長い。
船の動き(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秒の刻みで何回にも分けて計算するので、端末の速さで描くコマの数が変わっても、船の動きは同じになる。
舵角は自動操舵が決める。針路のずれeに比例して舵を切り、回っている速さrのぶんだけ戻すので、行き過ぎが小さい。舵そのものも1秒に2.5°までしか動かない。長さ12 mの漁船を25ノットで走らせてもTは1.5秒ほどあり、この形の式は刻みがTの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 });}
右舷側を塗り、左舷側を白抜きにして左右を見分ける。灯火の箱は、相手の船首から見た自分の向きθを使い、船首からs、右舷へlの位置にある灯火を、横方向ssinθ−lcosθの位置に描く。真正面からは2つのマスト灯が縦に並び、真横からは前後に開いて見える。夜の箱は、白と黒のうち暗いほうで塗るので、表示を反転しても夜に見える。