このシミュレーションは、1/120秒ごとに「機体にはたらく力を計算する → 速さ・位置・角度を少しだけ進める」をくり返している。地上にいるあいだは前後の動きだけを、浮いてからは前後と上下の両方を解く。V1の速さと必要な滑走路の長さは、条件を変えるたびに同じ計算を何十回も先回りして走らせて求めている。
長さはm、速さはm/s、力はN、角度はラジアンで計算し、画面に出すときだけktやkm/h、度に直す。翼面積・最大離陸重量・エンジンの推力などは、メーカーの空港計画の資料と、公開されている航空機の性能のデータから取った。
計算は「今の力で速さを進め、新しい速さで位置を進める」順の、いちばん単純な方法(半陰的オイラー法)で、刻みを1/120秒と細かくして誤差を抑えている。刻みは画面のコマ数に関係なく一定なので、速い端末でも遅い端末でも同じ離陸になる。機首の角度θ は、操縦桿で決めた角度cmdへ追いかける。上げる速さの上限は、計器の速さが80 ktから130 ktに上がるにつれて大きくなる(昇降舵が効くようになる)。尾部の下の点が地面より下へ行く角度になったら、そこで止めて「尾部が触れた」と数える。
描画面の幅と高さから、上の帯(滑走路の全体)・速度計・操縦桿・機体の位置を毎コマ決めるので、横長の画面でも縦長のスマホでも配置が崩れない。機体の形は主脚の接地点を原点にしたm単位の点の列で、tf.Xが機首の角度θ だけ回してから画面の座標に直す。
描画(src/sims/takeoff.ts の 295〜647 行) function layout(api: Api): Lay { const w = api.w, h = api.h, u = api.u; const narrow = w < 560 || h / w > 0.95 , tall = h / w > 1.3 ; const sy = u(8 ), sh = clamp(h * 0.15 , u(58 ), u(92 )), top = sy + sh + u(4 ), mh = h - top; // 機体の大きさ: 横幅の半分ほど(縦長の画面では 7 割)。高さは尾翼の 13 m が描画面の 3 割に収まるまで const k = Math.min((w * (tall ? 0.68 : narrow ? 0.58 : 0.5 )) / 40 , (mh * 0.3 ) / 13 ); const gy = top + mh * (tall ? 0.64 : narrow ? 0.66 : 0.68 ); const gr = clamp(Math.min(w, mh) * (narrow ? 0.12 : 0.11 ), u(34 ), u(66 )); const hs = Math.max(u(26 ), 32 ); const hy0 = top + u(64 ), hy1 = hy0 + clamp(mh * 0.32 , u(90 ), u(190 )); return { sx0: u(16 ), sx1: w - u(16 ), sy, sh, top, gy, k, px: w * (tall ? 0.43 : narrow ? 0.4 : 0.42 ), gx: u(14 ) + gr * 1.05 , gc: top + gr * 1.25 + u(10 ), gr, hx: w - Math.max(u(34 ), hs * 0.5 + u(16 )), hy0, hy1, hs, narrow, lw: Math.min(mh * 0.3 , (tall ? 24 : 16 ) * k, h - gy - u(12 ) + 2.6 * k), }; } // 機体の形(主脚の接地点を原点に、前が +x・上が +y、単位 m) const pts = (a: number[]): [number, number][] => { const o: [number, number][] = []; for (let i = 0 ; i < a.length; i += 2 ) o.push([a[i], a[i + 1 ]]); return o; };const FUSE: [number, number][] = (() => { const o: [number, number][] = []; // 上の線(後ろの端から機首の先へ) for (let i = 0 ; i <= 10 ; i++) { const x = -20.1 + (10.1 * i) / 10 ; o.push([x, 5.0 - 0.72 * Math.pow((-10 - x) / 10.1 , 2 )]); } for (let i = 1 ; i <= 12 ; i++) o.push([-10 + (25 * i) / 12 , 5.0 ]); for (let i = 1 ; i <= 10 ; i++) { const b = (i / 10 ) * (Math.PI / 2 ); o.push([15 + 4.4 * Math.sin(b), 2.95 + 2.05 * Math.cos(b)]); } // 下の線(機首の先から後ろの端へ)。後ろの下は、主脚から見て 11° の線より上を通る for (let i = 9 ; i >= 0 ; i--) { const b = (i / 10 ) * (Math.PI / 2 ); o.push([15 + 4.4 * Math.sin(b), 2.95 - 1.7 * Math.cos(b)]); } o.push(...pts([-6 , 1.25 , -9.5 , 1.95 , -13 , 2.62 , -16.5 , 3.21 , -18.6 , 3.75 , -20.1 , 4.1 ])); return o; })(); const FIN = pts([-11.0 , 5.0 , -13.2 , 5.7 , -18.0 , 12.4 , -20.0 , 12.4 , -19.7 , 9.0 , -19.5 , 4.6 ]);const STAB = pts([-15.4 , 4.45 , -17.0 , 4.8 , -21.3 , 5.1 , -21.6 , 4.95 , -17.2 , 4.4 ]);const WROOT = pts([5.6 , 1.55 , 5.1 , 1.92 , 3.8 , 2.14 , 1.5 , 2.12 , -1.2 , 1.86 , -1.2 , 1.66 , 0 , 1.52 , 3 , 1.38 , 5 , 1.38 ]);const WNEAR = pts([5.6 , 1.55 , -2.6 , 3.4 , -4.0 , 3.52 , -1.2 , 1.8 ]);const NAC = pts([8.1 , 1.45 , 7.95 , 2.3 , 5.0 , 2.36 , 3.4 , 2.15 , 2.6 , 1.66 , 2.6 , 1.3 , 3.4 , 0.78 , 5.5 , 0.56 , 7.9 , 0.62 , 8.1 , 0.95 ]);const PYLON = pts([5.6 , 2.34 , 4.6 , 2.8 , 1.8 , 2.62 , 2.4 , 2.2 ]); interface Tf { X(bx: number, by: number): [number, number]; ke: number } function drawPlane(api: Api, s: S, tf: Tf) { const u = api.u, mo = s.mo, P = (a: [number, number][]) => a.map(([x, y]) => tf.X(x, y)); const thin = { w: u(0.7 ) }, line = u(1.3 ); // 噴流(推力に比例した長さの破線) const ex = tf.X(2.4 , 1.48 ), dir = tf.X(-3 , 1.48 ); for (let k = 0 ; k < 2 ; k++) { if (mo.out[k]) continue ; const len = mo.sp[k] * 9 * tf.ke, dx = dir[0 ] - ex[0 ], dy = dir[1 ] - ex[1 ], d = Math.hypot(dx, dy) || 1 ; for (let j = -1 ; j <= 1 ; j++) { const oy = (j + k * 0.5 ) * 0.28 * tf.ke, off = ((mo.t * 9 + j * 0.37 ) % 1 ) * u(9 ); api.line(ex[0 ] + (dx / d) * off, ex[1 ] + oy + (dy / d) * off, ex[0 ] + (dx / d) * (off + len), ex[1 ] + oy + (dy / d) * (off + len), { w: u(0.6 ), dash: [u(5 ), u(4 )] }); } } api.poly(P(FIN), { close: true , fill: "paper" , w: line }); api.line(...tf.X(-18.4 , 12.3 ), ...tf.X(-18.2 , 4.9 ), thin); api.poly(P(FUSE), { close: true , fill: "paper" , w: line }); api.poly(P(STAB), { close: true , fill: "paper" , w: u(1 ) }); // 窓と扉 for (let x = 14.0 ; x > -12 ; x -= 0.52 ) { if (x < -0.8 && x > -1.7 ) continue ; const [X, Y] = tf.X(x, 3.55 ); api.dot(X, Y, Math.max(u(0.5 ), 0.11 * tf.ke)); } api.poly(P(pts([17.3 , 3.95 , 17.9 , 4.38 , 18.55 , 4.2 , 18.65 , 3.95 ])), { close: true , w: u(0.7 ) }); api.poly(P(pts([14.3 , 1.8 , 15.1 , 1.8 , 15.1 , 3.95 , 14.3 , 3.95 ])), { close: true , ...thin }); api.poly(P(pts([-13.7 , 2.75 , -12.9 , 2.6 , -12.9 , 4.35 , -13.7 , 4.35 ])), { close: true , ...thin }); api.poly(P(pts([-1.6 , 2.7 , -0.9 , 2.7 , -0.9 , 3.75 , -1.6 , 3.75 ])), { close: true , ...thin }); // 主翼(根元の断面・手前の翼)・フラップ・前縁の板 const fa = s.c.fl.ang * DEG, hx = -1.0 , hy = 1.75 , sl = Math.sin(fa) * 1.0 ; const rot = (x: number, y: number, a: number): [number, number] => [x * Math.cos(a) + y * Math.sin(a), -x * Math.sin(a) + y * Math.cos(a)]; const flap = pts([0.1 , 0.07 , -0.4 , 0.09 , -1.7 , 0.0 , -0.4 , -0.06 , 0.1 , -0.06 ]).map(([x, y]) => { const [rx, ry] = rot(x, y, fa); return tf.X(hx - sl + rx, hy + ry); }); api.poly(P(WNEAR), { close: true , fill: "paper" , w: u(1 ) }); api.poly(P(WROOT), { close: true , fill: "paper" , w: line }); api.poly(flap, { close: true , fill: "paper" , w: u(1 ) }); const sa = -Math.min(fa, 14 * DEG); const slat = pts([0 , 0.12 , 0.7 , 0.05 , 0.75 , -0.08 , 0 , -0.06 ]).map(([x, y]) => { const [rx, ry] = rot(x, y, sa); return tf.X(5.4 + rx, 1.62 + ry); }); api.poly(slat, { close: true , fill: "paper" , w: u(0.8 ) }); // エンジン api.poly(P(PYLON), { close: true , fill: "paper" , w: u(1 ) }); api.poly(P(NAC), { close: true , fill: "paper" , w: line }); api.line(...tf.X(5.5 , 2.34 ), ...tf.X(5.5 , 0.57 ), thin); api.line(...tf.X(7.75 , 2.2 ), ...tf.X(7.75 , 0.72 ), thin); api.poly(P(pts([2.6 , 1.62 , 1.9 , 1.48 , 2.6 , 1.34 ])), { close: true , w: u(0.8 ) }); // 脚(しまうと短くなって消える) const g = mo.gear; if (g > 0.05 ) { const wheel = (cx: number, r: number, top: number) => { const cy = r + (1 - g) * (top - r); api.line(...tf.X(cx, cy), ...tf.X(cx + 0.15 , top), { w: u(1.6 ) }); const [X, Y] = tf.X(cx, cy), R = r * tf.ke * (0.4 + 0.6 * g); api.circle(X, Y, R, { fill: "paper" , w: u(1.1 ) }); const a = -mo.x / r; api.line(X, Y, X + Math.cos(a) * R * 0.85 , Y + Math.sin(a) * R * 0.85 , { w: u(0.7 ) }); api.circle(X, Y, R * 0.35 , { w: u(0.6 ) }); }; wheel(0 , 0.565 , 1.7 ); wheel(15.6 , 0.345 , 1.35 ); } // 止まったエンジンの印 if (mo.out[0 ]) { const [X, Y] = tf.X(5.3 , 1.45 ), r = u(6 ); api.line(X - r, Y - r, X + r, Y + r, { w: u(1.6 ) }); api.line(X - r, Y + r, X + r, Y - r, { w: u(1.6 ) }); } } /** 描画面からはみ出さないように位置をずらして、地の色の札の上に文字を書く(幅は等幅の文字の数から見積もる) */ function labelIn(api: Api, t: string, x: number, y: number, fs: number, al: CanvasTextAlign = "left" ) { let wd = 0 ; for (const ch of t) wd += ch.charCodeAt(0 ) > 255 ? fs : fs * 0.6 ; const x0 = al === "right" ? x - wd : al === "center" ? x - wd / 2 : x; const dx = Math.max(api.u(4 ) - x0, 0 ) + Math.min(api.w - api.u(4 ) - (x0 + wd), 0 ); api.label(t, x + dx, y, { size: fs, font: "mono" , align: al }); } /** 力の矢印。重さ W を長さ lw として、ほかの力は W との比で描く(本物の比率) */ function drawForces(api: Api, s: S, tf: Tf, L: Lay) { const u = api.u, mo = s.mo, W = s.c.W, lw = L.lw * (tf.ke / L.k), nar = L.narrow; const cg = tf.X(1.5 , 2.6 ), fs = u(nar ? 10.5 : 11.5 ); const lab = (t: string, x: number, y: number, al: CanvasTextAlign = "left" ) => labelIn(api, t, x, y, fs, al); const kn = (f: number) => ` ${fmt(f / 1000, 0)} kN` ; const arr = (x: number, y: number, dx: number, dy: number, f: number, o: { dash?: number[]; w?: number } = {}) => { const len = (f / W) * lw; if (len < 1 ) return null ; const x2 = x + dx * len, y2 = y + dy * len; api.arrow(x, y, x2, y2, { w: o.w ?? u(2 ), head: Math.min(u(9 ), Math.max(u(4 ), len * 0.5 )), dash: o.dash }); return [x2, y2] as [number, number]; }; const g = mo.gam, vx = Math.cos(g), vy = -Math.sin(g); // 進む向き(画面の座標) // 重さ W(真下) const w2 = arr(cg[0 ], cg[1 ], 0 , 1 , W, { w: u(2.4 ) }); if (w2) lab(nar ? ` W ${kn(W)}` : ` 重さ W ${kn(W)}` , w2[0 ] + u(7 ), w2[1 ] - u(4 )); // 揚力 L(進む向きに垂直) const lp = tf.X(0.3 , 2.3 ); const l2 = arr(lp[0 ], lp[1 ], vy, -vx, Math.max(0 , mo.L), { w: u(2.4 ) }); if (l2) lab(nar ? ` L ${kn(mo.L)}` : ` 揚力 L ${kn(mo.L)}` , l2[0 ] + u(7 ), l2[1 ] + u(4 )); // 推力 T(機体の向き) const tp = tf.X(8.2 , 1.45 ), th = mo.th; const t2 = arr(tp[0 ], tp[1 ], Math.cos(th), -Math.sin(th), mo.T, { w: u(2.2 ) }); if (t2) lab(nar ? ` T ${kn(mo.T)}` : ` 推力 T ${kn(mo.T)}` , t2[0 ] + u(6 ), t2[1 ] - u(8 )); // 抗力 D(進む向きと逆) const dp = tf.X(-20.1 , 4.2 ); const d2 = arr(dp[0 ], dp[1 ], -vx, -vy, mo.D, { w: u(2 ) }); lab(nar ? ` D ${kn(mo.D)}` : ` 抗力 D ${kn(mo.D)}` , (d2 ? d2[0 ] : dp[0 ]) - u(6 ), (d2 ? d2[1 ] : dp[1 ]) - u(8 ), "right" ); if (mo.ground && mo.N > 0 ) { // 地面が押し返す力 N と、車輪の摩擦 μN const wp = tf.X(0 , 0 ), len = (mo.N / W) * lw; if (len > 1 ) api.arrow(wp[0 ] - u(3 ), wp[1 ] + len, wp[0 ] - u(3 ), wp[1 ], { w: u(1.4 ), dash: [u(4 ), u(3 )], head: Math.min(u(8 ), len * 0.5 ) }); arr(wp[0 ] - u(3 ), wp[1 ] + u(2 ), -1 , 0 , mo.F, { w: u(1.8 ) }); lab(` N ${kn(mo.N)} μN ${kn(mo.F)}` , wp[0 ] + 1.5 * tf.ke + u(10 ), wp[1 ] + u(20 )); } } /** 角度の注記: 機首の角度 θ・迎え角 α・尾部が触れるまでの余裕 */ function drawAngles(api: Api, s: S, tf: Tf, L: Lay) { const u = api.u, mo = s.mo, fs = u(L.narrow ? 10.5 : 11.5 ); const n = tf.X(19.6 , 2.95 ), len = (L.narrow ? 6.5 : 9 ) * tf.ke, r = len * 0.72 ; const th = mo.th, g = mo.gam; api.line(n[0 ], n[1 ], n[0 ] + len, n[1 ], { w: u(0.7 ), dash: [u(2 ), u(3 )] }); api.line(n[0 ], n[1 ], n[0 ] + Math.cos(th) * len, n[1 ] - Math.sin(th) * len, { w: u(0.9 ), dash: [u(7 ), u(2 ), u(1 ), u(2 )] }); const arc = (a0: number, a1: number, rr: number) => { const p: [number, number][] = []; for (let i = 0 ; i <= 16 ; i++) { const a = a0 + ((a1 - a0) * i) / 16 ; p.push([n[0 ] + Math.cos(a) * rr, n[1 ] - Math.sin(a) * rr]); } api.poly(p, { w: u(1 ) }); }; if (th > 0.3 * DEG) arc(0 , th, r); if (!mo.ground) { api.line(n[0 ], n[1 ], n[0 ] + Math.cos(g) * len * 1.1 , n[1 ] - Math.sin(g) * len * 1.1 , { w: u(0.8 ), dash: [u(1.5 ), u(3 )] }); arc(g, th, r * 0.7 ); labelIn(api, ` α ${fmt((th - g) / DEG, 1)}°` , n[0 ] + Math.cos(g) * len * 1.1 + u(6 ), n[1 ] - Math.sin(g) * len * 1.1 + u(12 ), fs); } labelIn(api, mo.ground && !L.narrow ? ` θ ${fmt(th / DEG, 1)}°(= α)` : ` θ ${fmt(th / DEG, 1)}°` , n[0 ] + Math.cos(th) * len + u(4 ), n[1 ] - Math.sin(th) * len - u(4 ), fs); // 尾部の余裕: 主脚の接地点から尾部の下の点へ引いた線と、地面のあいだの角度 if (mo.ground && mo.th > 1 * DEG && !s.done) { const o = tf.X(0 , 0 ), t = tf.X(-TAIL_X, TAIL_Y), m = TAIL_ANG - mo.th; const wedge: [number, number][] = [o, t, [t[0 ], o[1 ]]]; api.hatch(wedge, { gap: u(3.2 ), angle: -60 , w: u(0.6 ) }); api.line(o[0 ], o[1 ], t[0 ], t[1 ], { w: u(0.8 ), dash: [u(3 ), u(2 )] }); labelIn(api, m > 0.05 * DEG ? ` 尾部まで ${fmt(m / DEG, 1)}°` : "尾部が接地" , t[0 ] + u(4 ), o[1 ] + u(22 ), fs); } } function drawGround(api: Api, s: S, L: Lay, ke: number, gyS: number, camX: number) { const u = api.u, w = api.w, h = api.h; const X = (x: number) => L.px + (x - camX) * ke; // 遠くの山(ゆっくり流れる) if (gyS < h + u(30 )) { const hp: [number, number][] = [], sh = camX * ke * 0.04 ; for (let i = 0 ; i <= 40 ; i++) { const xx = (i / 40 ) * w; const v = Math.sin((xx + sh) * 0.011 ) * 0.6 + Math.sin((xx + sh) * 0.027 + 1.3 ) * 0.4 ; hp.push([xx, gyS - u(10 ) - (v + 1 ) * u(9 )]); } api.poly(hp, { w: u(0.6 ) }); } if (gyS > h) return ; // 地面(斜線)・滑走路の先は芝生(点) const re = X(RWY), rc = Math.min(w, Math.max(0 , re)); if (rc > 0 ) api.hatch([[0 , gyS], [rc, gyS], [rc, h], [0 , h]], { gap: u(9 ), angle: 45 , w: u(0.55 ) }); if (re < w) api.dots([[rc, gyS], [w, gyS], [w, h], [rc, h]], { gap: u(7 ), r: u(0.9 ) }); api.line(0 , gyS, w, gyS, { w: u(1.4 ) }); const a0 = Math.max(0 , X(0 )), a1 = Math.min(w, re); if (a1 > a0) api.line(a0, gyS, a1, gyS, { w: u(3 ) }); // 灯火(60 m ごと)と距離の目盛り(100 m ごと・500 m ごとに数字) const x0 = camX - L.px / ke - 100 , x1 = camX + (w - L.px) / ke + 100 ; for (let x = Math.ceil(Math.max(0 , x0) / 60 ) * 60 ; x <= Math.min(RWY, x1); x += 60 ) { const xx = X(x); api.line(xx, gyS, xx, gyS - u(5 ), { w: u(0.7 ) }); api.circle(xx, gyS - u(6.5 ), u(1.6 ), { fill: "paper" , w: u(0.8 ) }); } for (let x = Math.ceil(Math.max(0 , x0) / 100 ) * 100 ; x <= Math.min(RWY + 1000 , x1); x += 100 ) { const xx = X(x); api.line(xx, gyS, xx, gyS + u(x % 500 === 0 ? 10 : 5 ), { w: u(1 ) }); if (x % 500 === 0 && x < RWY) api.label(` ${km(x)} m` , xx, gyS + u(26 ), { size: u(11 ), font: "mono" , align: "center" }); } if (re > -u(100 ) && re < w + u(100 )) { api.line(re, gyS - u(26 ), re, gyS + u(14 ), { w: u(1.6 ) }); for (let i = 0 ; i < 4 ; i++) api.circle(re + u(4 ) + i * u(6 ), gyS - u(4 ), u(1.8 ), { fill: "ink" , stroke: false }); api.label(` 滑走路の端 ${km(RWY)} m` , re - u(6 ), gyS - u(32 ), { size: u(11.5 ), align: "right" }); } const st = X(0 ); if (st > -u(60 ) && st < w) api.line(st, gyS - u(14 ), st, gyS + u(10 ), { w: u(1.2 ) }); // 吹き流し(風があるとき。向かい風なので後ろへなびく) if (s.c.wind > 0.5 ) { for (let x = 150 ; x <= RWY; x += 700 ) { const xx = X(x); if (xx < -u(40 ) || xx > w + u(40 )) continue ; const top = gyS - u(46 ), len = u(10 ) + u(22 ) * clamp(s.c.wind / (25 * KT), 0 , 1 ); api.line(xx, gyS, xx, top, { w: u(1 ) }); const sock: [number, number][] = [[xx, top], [xx - len, top + u(3 )], [xx - len, top + u(7 )], [xx, top + u(10 )]]; api.poly(sock, { close: true , fill: "paper" , w: u(0.9 ) }); api.hatch(sock, { gap: u(4 ), angle: 90 , w: u(0.8 ) }); } } } function drawStrip(api: Api, s: S, L: Lay) { const u = api.u, mo = s.mo, p = s.p, fs = u(L.narrow ? 10 : 11 ); const xa = -120 , xb = RWY + 700 , X = (x: number) => L.sx0 + ((x - xa) / (xb - xa)) * (L.sx1 - L.sx0); const by = L.sy + L.sh * 0.6 , bh = u(6 ), kz = (by - L.sy - u(24 )) / 40 , Y = (hh: number) => by - Math.min(hh, 40 ) * kz; // 滑走路と、その先の芝生 api.rect(X(0 ), by, X(RWY) - X(0 ), bh, { fill: "paper" , w: u(1.1 ) }); api.line(X(0 ) + u(4 ), by + bh / 2 , X(RWY) - u(4 ), by + bh / 2 , { w: u(0.6 ), dash: [u(4 ), u(3 )] }); api.dots([[X(RWY), by], [X(xb), by], [X(xb), by + bh], [X(RWY), by + bh]], { gap: u(3.5 ), r: u(0.6 ) }); for (let x = 0 ; x <= RWY; x += 500 ) { api.line(X(x), by + bh, X(x), by + bh + u(4 ), { w: u(0.8 ) }); if (!L.narrow || x % 1000 === 0 || x === RWY) api.text(x === RWY ? ` ${km(x)} m` : km(x), X(x), by + bh + u(14 ), { size: fs, font: "mono" , align: "center" }); } // 35 ft の線と、通った経路(高さは 40 m までを大きく描く) api.line(X(0 ), Y(SCREEN), X(xb), Y(SCREEN), { w: u(0.5 ), dash: [u(2 ), u(3 )] }); api.text("35 ft" , X(xb), Y(SCREEN) - u(3 ), { size: fs * 0.9 , font: "mono" , align: "right" }); if (s.pathN > 1 ) { const q: number[] = []; for (let i = 0 ; i < s.pathN; i++) q.push(X(s.path[i * 2 ]), Y(s.path[i * 2 + 1 ])); q.push(X(mo.x), Y(mo.h)); api.poly(q, { w: u(1.3 ) }); } // 出来事の位置 const marks: [number, string][] = []; if (s.ev.v1 >= 0 ) marks.push([s.ev.v1, "V1" ]); if (s.ev.vr >= 0 ) marks.push([s.ev.vr, "VR" ]); if (s.ev.lof >= 0 ) marks.push([s.ev.lof, L.narrow ? "浮" : "浮いた" ]); if (s.ev.s35 >= 0 ) marks.push([s.ev.s35, "35ft" ]); let lastX = -1e9 , row = 0 ; for (const [x, t] of marks) { const xx = X(x); row = xx - lastX < u(30 ) ? row + 1 : 0 ; lastX = xx; api.line(xx, by - u(2 ), xx, by - u(8 ) - row * u(11 ), { w: u(0.7 ) }); api.text(t, xx + u(2 ), by - u(10 ) - row * u(11 ), { size: fs * 0.95 , font: "mono" }); } // 機体の位置 const mx = X(mo.x), my = Math.min(Y(mo.h), by - u(1 )); api.poly([[mx + u(6 ), my - u(2 )], [mx - u(5 ), my - u(6 )], [mx - u(5 ), my + u(1 )]], { close: true , fill: "ink" , stroke: false }); // 必要な滑走路の長さ const need = p.need, ny = L.sy + u(4 ), nx = X(Math.min(need, xb - 10 )); api.line(X(0 ), ny + u(4 ), nx, ny + u(4 ), { w: u(1.6 ) }); api.line(X(0 ), ny, X(0 ), ny + u(8 ), { w: u(1 ) }); api.line(nx, ny, nx, ny + u(8 ), { w: u(1 ) }); const txt = need > 6000 ? "必要な滑走路 6,000 m 以上(足りない)" : ` 必要な滑走路 ${km(need)} m${need > RWY ? "(足りない)" : ""}` ; api.label(txt, need > RWY ? nx - u(4 ) : nx + u(6 ), ny + u(9 ), { size: fs, align: need > RWY ? "right" : "left" }); } function drawGauge(api: Api, s: S, L: Lay) { const u = api.u, { gx, gc: cy, gr: r } = L, p = s.p; const ang = (v: number) => (120 + (300 * clamp(v, 0 , 200 )) / 200 ) * DEG; const at = (v: number, rr: number): [number, number] => [gx + Math.cos(ang(v)) * rr, cy + Math.sin(ang(v)) * rr]; api.circle(gx, cy, r, { fill: "paper" , w: u(1.5 ) }); api.circle(gx, cy, r * 0.93 , { w: u(0.6 ) }); // 失速速度 VS より遅い所に斜線 const vs = p.vs / KT, band: [number, number][] = []; for (let k = 0 ; k <= 20 ; k++) band.push(at((vs * k) / 20 , r * 0.93 )); for (let k = 20 ; k >= 0 ; k--) band.push(at((vs * k) / 20 , r * 0.82 )); api.hatch(band, { gap: u(2.6 ), angle: 45 , w: u(0.6 ) }); for (let v = 0 ; v <= 200 ; v += 10 ) { const a = at(v, r * 0.93 ), b = at(v, r * (v % 40 === 0 ? 0.74 : v % 20 === 0 ? 0.8 : 0.86 )); api.line(a[0 ], a[1 ], b[0 ], b[1 ], { w: u(v % 20 === 0 ? 1.2 : 0.7 ) }); } for (let v = 0 ; v <= 200 ; v += 40 ) { const t = at(v, r * 0.58 ); api.text(String(v), t[0 ], t[1 ] + r * 0.07 , { size: r * 0.17 , font: "mono" , align: "center" }); } const bugs: [number, string][] = [[p.v1 / KT, "V1" ], [p.vr / KT, "VR" ], [p.v2 / KT, "V2" ]]; bugs.forEach(([v, t], i) => { const a = ang(v), c = Math.cos(a), sn = Math.sin(a), r0 = r * 1.02 , r1 = r * 1.16 ; api.poly([[gx + c * r0, cy + sn * r0], [gx + c * r1 - sn * r * 0.06 , cy + sn * r1 + c * r * 0.06 ], [gx + c * r1 + sn * r * 0.06 , cy + sn * r1 - c * r * 0.06 ]], { close: true , fill: "ink" , stroke: false }); const rr = r * (1.32 + (i % 2 ) * 0.34 ); api.label(t, gx + c * rr, cy + sn * rr + u(4 ), { size: u(L.narrow ? 10 : 11 ), font: "mono" , align: "center" , pad: u(1 ) }); }); const vk = (s.mo.V * Math.sqrt(s.c.sig)) / KT, n = at(vk, r * 0.86 ); api.line(gx, cy, n[0 ], n[1 ], { w: u(2.2 ) }); api.circle(gx, cy, u(3.2 ), { fill: "ink" , stroke: false }); api.text(fmt(vk, 0 ), gx, cy + r * 0.47 , { size: r * 0.25 , font: "mono" , align: "center" , weight: 600 }); api.text("kt" , gx, cy + r * 0.68 , { size: r * 0.14 , font: "mono" , align: "center" }); api.text("速度計" , gx, cy + r + u(16 ), { size: u(L.narrow ? 10 : 11 ), align: "center" }); // エンジンの推力(2 本の棒) const ex = gx + r * 1.95 + u(10 ), eh = r * 0.95 , ey = cy - eh * 0.2 , ew = u(8 ); api.text("推力" , ex + ew * 1.1 , ey - u(6 ), { size: u(10.5 ), align: "center" }); for (let e = 0 ; e < 2 ; e++) { const x = ex + e * ew * 2.2 , f = s.mo.out[e] ? s.mo.sp[e] : s.mo.sp[e] * s.c.thr; api.rect(x, ey, ew, eh, { fill: "paper" , w: u(0.9 ) }); const hh = clamp(f, 0 , 1 ) * eh; if (hh > 0.5 ) api.rect(x, ey + eh - hh, ew, hh, { fill: "ink" , stroke: false }); api.text(String(e + 1 ), x + ew / 2 , ey + eh + u(13 ), { size: u(10.5 ), font: "mono" , align: "center" }); if (s.mo.out[e]) { api.line(x - u(2 ), ey + eh * 0.3 , x + ew + u(2 ), ey + eh * 0.7 , { w: u(1.4 ) }); api.line(x - u(2 ), ey + eh * 0.7 , x + ew + u(2 ), ey + eh * 0.3 , { w: u(1.4 ) }); } } } function drawStick(api: Api, s: S, L: Lay) { const u = api.u, { hx, hy0, hy1, hs } = L, fs = u(L.narrow ? 10 : 11 ); const Y = (deg: number) => hy0 + (clamp(deg, 0 , CMD_MAX) / CMD_MAX) * (hy1 - hy0); api.label("操縦桿" , hx + hs * 0.5 + u(8 ), hy0 - hs * 0.5 - u(30 ), { size: u(L.narrow ? 11 : 12 ), align: "right" , pad: u(1.5 ) }); api.label(L.narrow ? "下へ引く" : "下へ引くと機首が上がる" , hx + hs * 0.5 + u(8 ), hy0 - hs * 0.5 - u(14 ), { size: fs, align: "right" , pad: u(1.5 ) }); api.rect(hx - u(4 ), hy0 - u(4 ), u(8 ), hy1 - hy0 + u(8 ), { fill: "paper" , w: u(0.9 ) }); for (let d = 0 ; d <= CMD_MAX; d += 5 ) { const y = Y(d); api.line(hx - hs * 0.5 - u(7 ), y, hx - hs * 0.5 - u(2 ), y, { w: u(0.8 ) }); api.label(` ${d}°` , hx - hs * 0.5 - u(9 ), y + fs * 0.35 , { size: fs, font: "mono" , align: "right" , pad: u(1.5 ) }); } const ty = Y(s.mo.th / DEG); api.poly([[hx + hs * 0.5 + u(2 ), ty], [hx + hs * 0.5 + u(9 ), ty - u(4 )], [hx + hs * 0.5 + u(9 ), ty + u(4 )]], { close: true , fill: "ink" , stroke: false }); const y = Y(s.mo.cmd / DEG); api.rect(hx - hs / 2 , y - hs / 2 , hs, hs, { fill: "paper" , w: u(1.8 ) }); api.line(hx - hs * 0.28 , y, hx + hs * 0.28 , y, { w: u(1 ) }); api.line(hx, y - hs * 0.28 , hx, y + hs * 0.28 , { w: u(1 ) }); const vr = s.mo.V * Math.sqrt(s.c.sig) >= s.p.vr; if (!api.bool("auto" ) && vr && s.mo.cmd < 2 * DEG && s.mo.ground && !s.done && !s.mo.reject && api.t % 1 < 0.65 ) api.circle(hx, y, hs * 0.95 , { w: u(1.2 ), dash: [u(3 ), u(3 )] }); } function button(api: Api, s: S, b: Btn, t: string) { const u = api.u; api.rect(b.x, b.y, b.w, b.h, { fill: "paper" , w: u(1.4 ) }); api.rect(b.x + u(3 ), b.y + u(3 ), b.w - u(6 ), b.h - u(6 ), { w: u(0.5 ), dash: [u(2 ), u(2 )] }); api.text(t, b.x + b.w / 2 , b.y + b.h / 2 + u(4.5 ), { size: u(12.5 ), align: "center" , weight: 600 }); s.btn.push(b); } function draw(api: Api, s: S) { const L = (s.lay = layout(api)), u = api.u, w = api.w, h = api.h, mo = s.mo; api.clear(); // カメラ: 機体を追い、上がるにつれて少し引く const zoom = 1 / (1 + clamp((mo.h - 4 ) / 40 , 0 , 1 )); const ke = L.k * zoom, camH = Math.max(0 , mo.h - 5 ); const gyS = L.gy + camH * ke, camX = mo.x; const c = Math.cos(mo.th), sn = Math.sin(mo.th); const tf: Tf = { ke, X: (bx, by) => [L.px + (bx * c - by * sn) * ke, gyS - (mo.h + bx * sn + by * c) * ke] }; // 雲(遠いので、ゆっくり流れる) const per = w * 1.7 ; for (let i = 0 ; i < 4 ; i++) { const cx = (((((i * 0.43 + 0.1 ) * per - camX * ke * 0.12 ) % per) + per) % per) - w * 0.35 ; const cy = L.top + (L.gy - L.top) * (0.12 + 0.13 * i) + camH * ke * 0.25 ; if (cy > h) continue ; const rw = u(34 + 10 * (i % 3 )), rh = u(9 + 3 * (i % 2 )), q: [number, number][] = []; for (let k = 0 ; k <= 28 ; k++) { const a = (k / 28 ) * Math.PI * 2 ; q.push([cx + Math.cos(a) * rw * (1 + 0.15 * Math.sin(a * 3 + i)), cy + Math.sin(a) * rh * (Math.sin(a) < 0 ? 1.4 : 0.6 )]); } api.poly(q, { close: true , w: u(0.9 ), dash: [u(1.2 ), u(3 )] }); } drawGround(api, s, L, ke, gyS, camX); // 35 ft の高さの線 if (mo.lofT >= 0 || mo.V * Math.sqrt(s.c.sig) > 0.8 * s.p.vr) { const y35 = gyS - SCREEN * ke; if (y35 > L.top && y35 < h) { api.line(0 , y35, w, y35, { w: u(0.7 ), dash: [u(6 ), u(4 )] }); api.label("35 ft(10.7 m)" , u(12 ), y35 - u(5 ), { size: u(10.5 ), font: "mono" }); } } drawPlane(api, s, tf); drawAngles(api, s, tf, L); drawForces(api, s, tf, L); drawStrip(api, s, L); drawGauge(api, s, L); drawStick(api, s, L); // 画面の上のボタン s.btn = []; const bw = u(L.narrow ? 132 : 150 ), bh = Math.max(u(30 ), 32 ), b: Btn = { x: w - bw - u(12 ), y: h - bh - u(12 ), w: bw, h: bh, act: "fail" }; if (s.done) button(api, s, { ...b, act: "again" }, "もう一度" ); else if (mo.out[0 ] && mo.ground && !mo.reject && mo.x > 1 ) button(api, s, { ...b, act: "reject" }, "離陸をやめる" ); else if (!mo.out[0 ]) button(api, s, b, "エンジン 1 を止める" ); // 状態 api.label(` ${api.bool("auto") ? "自動で操縦" : "手で操縦"} ${fmt(mo.t, 1)} 秒` , u(12 ), h - u(16 ), { size: u(11.5 ), font: "mono" }); if (s.done) { const msg = s.done === "ok" ? ` 35 ft までの距離 ${km(s.ev.s35)} m${mo.tail > 0 ? "(尾部を擦った)" : ""}` : s.done === "stop" ? ` 止まった 残り ${km(RWY - mo.x)} m` : s.done === "over" ? "滑走路の端を越えた" : s.done === "crash" ? "地面に強く当たった" : "時間切れ" ; api.label(msg, w / 2 , L.top + (L.gy - L.top) * 0.12 + u(20 ), { size: u(16 ), align: "center" , weight: 600 }); } if (s.wait > 0 ) api.label("条件を変えた: 速さの印を計算しています" , w / 2 , L.top + u(30 ), { size: u(13 ), align: "center" }); }
取っ手をつかむと、指の高さがそのまま機首の角度の目標になる(上端が0°、下端が20°)。取っ手以外の場所を縦になぞったときは、動かした量だけ目標を変える。どちらも、触った時点で自動の操縦を切る。