旅客機は速さだけでは浮かない。機首を上げて迎え角をつくる

ボーイング737-800は時速280 km近くまで加速してから機首を上げ、その数秒後に車輪が滑走路を離れる。機首を上げるのが早すぎれば尾部を擦り、遅すぎれば滑走路が足りなくなる。

記事

旅客機は速さだけでは浮かない。機首を上げて迎え角をつくる

ボーイング737-800は時速280 km近くまで加速してから機首を上げ、その数秒後に車輪が滑走路を離れる。機首を上げるのが早すぎれば尾部を擦り、遅すぎれば滑走路が足りなくなる。

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

現象

2,500 mの滑走路に、余裕は140 m

羽田空港の4本の滑走路のうち、B滑走路とD滑走路は長さが2,500 mある1。2クラスで160席のボーイング737-800を、最大離陸重量の79 tにしたとする。ボーイングが空港を設計する人に向けて出している資料によると、海面・気温15 °C・無風の乾いた滑走路から離陸させるには、約2,360 mの滑走路が要る2。2,500 mの滑走路に残る余裕は、140 mしかない。

ただ、車輪が地面を転がる距離そのものは、この長さより短い。必要な長さには、エンジンが1基止まったときのための余裕が入っている。止まったのが走り出してまもなくなら、ブレーキをかけて滑走路の中で止まる。浮く直前なら、残った1基で離陸を続ける。どちらを選んでも滑走路に収まるように、長さが決められている。

もう1つ、旅客機は速さが上がっただけでは浮かない。窓の外の景色が速く流れ、背中が座席に押しつけられたあと、床が後ろへ傾く。車輪の音が消えるのは、その少しあとである。床が傾くのは、操縦士が機首を上げたからだ。この機首上げ(ローテーション)を始める速さと、上げる速さが、離陸を左右する。

仕組み

揚力は、速さの2乗と迎え角で決まる

滑走中の旅客機には、5つの力がはたらく。前へ押す推力、空気が後ろへ押し戻す抗力、真下へ引く重さ、主翼が生む上向きの揚力、そして車輪が転がるときの摩擦である。摩擦は、地面が車輪を押し返す力に比例する。揚力が増えるほど車輪にかかる重さは減るので、と書ける。進む向きの運動方程式は次のとおりだ。

は機体の質量(kg)、は速さ(m/s)、は転がりの摩擦係数で、シミュレーションでは0.02にした。79 tの737-800では、重さは約775 kNになる。一方、2基のエンジンの推力は、止まっているときで合わせて約231 kN3。重さの3割しかない。つまり、機体を持ち上げるのは推力ではなく、主翼の揚力である。

矢印の長さは重さWとの比(浮く直前の737-800のおよその値) 尾部の下の点まで、あと3° (θが11°になると尾部が地面に触れる) 機首の角度θ = 8° 重さW 揚力L 推力T 抗力D 地面が押す力N 車輪の摩擦μN(とても小さい)
滑走路で機首を上げた旅客機にはたらく力。矢印の長さは重さとの比

揚力は、次の式で表される。

は空気の密度(海面・15 °Cで1.225 kg/m³)、は主翼の面積(737-800で124.6 m²)、は揚力係数である。速さが2倍になれば、揚力は4倍になる。この式で計算すると、150 kt(時速278 km)で79 tを支えるには、が約1.7要る。

を決めるのは、翼に当たる風と翼のなす角、つまり迎え角だ。車輪が3本とも地面に着いているあいだは機体の姿勢が変わらないので、迎え角も小さいまま。いくら速くなってもは足りない。そこで操縦士は操縦桿を引き、尾翼の昇降舵で主脚を軸に機首を上げる。機首の角度が増えた分だけ迎え角が増え、揚力が重さを超えたところで機体は浮く。迎え角を大きくしすぎると、翼の上面の流れがはがれて揚力が急に減る。これが失速である。

迎え角を大きくすると、翼の上を回る流れが速くなり、上の面の圧力が下がる。この差が揚力のもとになる。下の図の翼は、後ろの縁の四角を上下に動かすと迎え角が変わり、網点の濃い所(圧力の低い所)が翼の上に広がっていく。

機首を上げられる角度には、もう1つ上限がある。主脚を軸に機首が上がると、そのぶん胴体の後ろが下がるからだ。737-800では、機首の角度が11°になると尾部が滑走路に触れる4。浮く前に11°に届けば、尾部を擦りながら走ることになる。機首上げを早く始めすぎてはいけない理由が、ここにある。

ところが、機首を上げると空気の抵抗も増える。

は機体の形で決まる分、は揚力を生むのに伴う分(誘導抗力)で、を大きくするほど急に増える。シミュレーションでは737-800の値として、を使った5。翼が地面のすぐ近くにあると、翼の端から巻く渦が地面に押さえられ、誘導抗力が小さくなる。いわゆる地面効果。

主翼の後ろの縁から板を出すフラップは、同じ迎え角でもを大きくし、失速する手前の最大のも上げる。浮く速さが下がる代わりに、抗力は増える。

空気の密度は、気圧と、絶対温度で表した気温(K)で決まる。

は空気の気体定数(287 J/(kg·K))だ。標高が高い空港では気圧が低く、暑い日は気温が高い。どちらもが小さくなるため、同じ揚力を得るには、より速く走らなければならない。しかも薄い空気では、エンジンの推力まで落ちてしまう。

仕組み

V1・VR・V2は、エンジンが止まる場合に備えた速さ

操縦士は離陸の前に、その日の重さや気温から3つの速さを決め、速度計に印を付ける。

  • V1(離陸決定速度)は、離陸をやめる操作を始められる最後の速さ。これより前にエンジンが止まればブレーキをかけ、過ぎていれば止まらずに離陸を続ける
  • VR(ローテーション速度)は、機首上げを始める速さ
  • V2(安全離陸速度)は、片方のエンジンが止まっても安全に上昇できる速さ

米国の耐空性の基準では、V2は失速速度の1.13倍以上で、高さ35 ft(10.7 m)に届くまでにこの速さに達していなければならない6。必要な滑走路の長さは、3つの距離のうち最も長いものになる。全部のエンジンで35 ftに届くまでの距離の1.15倍。V1の直前でエンジンが止まり、離陸をやめて止まるまでの距離(V1の速さで2秒走る分を足す)。そして、V1の直後に止まって、残った1基で35 ftに届くまでの距離である。

V1を高くすると、止まるための距離は延び、続けるための距離は縮む。だから、2つが等しくなるV1を選べば、必要な滑走路はいちばん短くて済む。シミュレーションは、条件を変えるたびにこのV1を計算で探している。

V1の直前にエンジンが止まる → 離陸をやめる V1で中止 止まる ブレーキ V1の直後にエンジンが止まる → 離陸を続ける 35 ft V1 VR 浮く V2 0 1,000 m 2,000 m 必要な滑走路の長さ(長いほうの距離、全部のエンジンなら35 ftまでの距離の1.15倍) 79 t・海面・15 °Cでのシミュレーションの値
V1の直前と直後にエンジンが止まったときの、止まる距離と続ける距離

最大離陸重量の79 t・海面・15 °C・フラップ5では、シミュレーションのV1が149 kt、VRが153 kt、V2が161 ktで、必要な滑走路は2,329 mになった。ボーイングの資料の約2,360 mに近い。

試す

機首を上げて、浮くまでを試す

自動の操縦では、VRで毎秒2.5°ずつ機首を上げる。描画面の右の操縦桿の取っ手を下へ引くと、自動が切れて自分で機首の角度を決められる(Spaceキーを押している間も上がる)。

  1. 70 tの機体で、ふつうに離陸する。速さが上がるにつれて揚力の矢印が伸び、地面が押す力の矢印が縮む。VRの144 ktで機首が上がり、が0になった1,343 mの地点で浮く。揚力は速さの2乗で増えるので、矢印が大きく伸びるのは後半の数百メートルだ。
  2. VRより30 kt早い114 ktで機首を上げる。機首の角度は11°で止まり、尾部が滑走路を擦る。この速さでは、11°まで上げても揚力が重さに届かないからだ。そのまま加速して138 ktほどで浮くが、失速速度の134 ktをわずかに上回るだけで、速さの余裕がない。
  3. 79 tの機体で、気温35 °Cの日に離陸する。空気の密度が下がり、エンジンの推力も落ちるので、必要な滑走路は2,564 mと2,500 mを超える。全部のエンジンが回っていれば2,228 mで35 ftに届くが、エンジンが止まったときに収まる余裕はない。
  4. 標高1,600 m・気温20 °Cの空港から、70 tで離陸する。VRは計器の速さでは海面と同じ144 ktでも、地面から見ると時速297 kmまで走る必要がある(海面では268 km)。空気が薄く、同じ揚力に大きな速さが要るからで、必要な滑走路は、海面・15 °Cの1,885 mから2,487 mに延びる。
  5. 75 tの機体で、V1を3 kt過ぎたところでエンジン1が止まる。自動の操縦は離陸を続け、1基の推力でV2を保って上昇する。35 ftに届く地点は1,947 mで、2基のときより100 mほど遠い。V1より前に描画面の「エンジン1を止める」を押すと、離陸をやめてブレーキで止まる。
  6. 79 tの機体で、向かい風25 ktの中を離陸する。止まっているときから翼に25 ktの風が当たるので、地面から見た速さは小さくて済む。35 ftに届く地点は、無風の2,028 mから1,528 mに縮む。
現実では

本物の737-800と、日本の滑走路

ボーイングの資料には、重さ・気温・空港の標高ごとに、必要な滑走路の長さを読み取る図が載っている。737-800のおもな値は次のとおりだ2。

条件(乾いた滑走路・無風)必要な滑走路の長さ
79 t・海面・15 °C約2,360 m
79 t・海面・30 °C約2,490 m
79 t・標高610 m・11 °C約2,620 m
79 t・標高610 m・26 °C約2,760 m
65 t・海面・15 °C約1,550 m

同じ重さでも、気温が15 °C上がると約130 m、標高610 mの空港なら約260 m、長い滑走路が要る。逆に、重さを79 tから65 tに減らせば約800 m短くて済む。資料には「運航には使わない」と書かれていて、あくまで空港の計画のための目安である。

羽田空港の滑走路は、Aが3,000 m、BとDが2,500 m、Cが3,360 mだ1。表の30 °Cの行の約2,490 mは、BとDの長さにほぼ等しい。滑走路がこれより短い空港や、もっと暑く標高の高い空港から飛ぶなら、燃料・貨物・乗客を減らして機体を軽くするしかない。

本物の速さの印は、英国の航空事故調査局(AAIB)の報告で読める。2020年2月、イングランドのイースト・ミッドランズ空港で強い横風の中を離陸しようとした737-800では、V1が134 kt、VRが149 kt、V2が156 ktと計算されていた7。この機はV1のあたりで風の急変に遭って速さが落ち、向きもそれた。機長はV1を過ぎてから離陸をやめ、機体は滑走路の残りが約600 mの地点で止まった。報告は、風の急変の中では、遅れて離陸をやめるより続けたほうが無事に済んだ例が多いという製造者の見解を引いている。

機首を上げる速さが行き過ぎた例もある。2022年3月、マンチェスター空港では訓練中の副操縦士が機首を速く上げすぎ、737-800の尾部が滑走路に触れた8。訓練で求められていた機首上げの速さは毎秒2〜2.5°だが、このときは最大で毎秒7.1°に達し、機首の角度は13°まで上がった。傷んだのは尾部の下の当て板(テールスキッド)などだけで、機体は空港へ引き返して着陸した。

省いていること

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

  • フラップごとの揚力係数と抗力の増え分は推定値で、必要な滑走路の長さがボーイングの資料の値に近くなるように選んだ。海面と標高610 mでは資料との差が1割以内だが、標高1,219 mの79 tでは資料より2割ほど短く出る
  • 機首の角度は、操縦桿で決めた角度へ追いかけるだけで、重心まわりの力のつり合いは解いていない。昇降舵の効きは、計器の速さ80 ktから130 ktにかけて強くなるとした
  • 地面効果は誘導抗力が減る分だけを入れ、揚力が増える分は省いた。そのため、浮いたあとに地面から離れて揚力が減る様子は出ない。翼のまわりの流れの図も粘りのない流れ(ポテンシャル流)の計算で、流れがはがれる失速は表さない
  • 横風と風の急変、滑走路の傾きやぬれ、タイヤの速さの限界、ブレーキの熱は入れていない。片方のエンジンで上昇するときの傾きの決まり(2.4%以上)も判定していない。離陸をやめるときのブレーキは摩擦係数0.4で、逆噴射は使わない
  • VRは失速速度の1.08倍、V2は1.13倍に固定した。推力はBartelとYoungの式で速さと気圧に応じて減らし、気温が標準より15 °C以上高い分は1 °Cごとに0.8%減らす推定にした
参考文献

参考文献

  1. 国土交通省航空局「国管理空港(空港概況)」2026. https://www.mlit.go.jp/koku/content/002021263.pdf
  2. Boeing Commercial Airplanes「737 Airplane Characteristics for Airport Planning」D6-58325-7 Rev C, 2025. https://www.boeing.com/content/dam/boeing/v2/airports/acaps/737NG_REV_C.pdf
  3. U.S. Government「Code of Federal Regulations, Title 14, Part 25(§25.107・§25.109・§25.111・§25.113・§25.121)」2024年1月1日版, 2024. https://www.govinfo.gov/content/pkg/CFR-2024-title14-vol1/pdf/CFR-2024-title14-vol1-sec25-107.pdf
  4. Air Accidents Investigation Branch「AAIB Bulletin 12/2020: Boeing 737-86N, G-DRTN」2020. https://assets.publishing.service.gov.uk/media/5fabbe4f8fa8f56d98259e85/Boeing_737-86N_G-DRTN_12-20.pdf
  5. Air Accidents Investigation Branch「AAIB Bulletin 8/2022: Boeing 737-8K5, G-TAWY」2022. https://assets.publishing.service.gov.uk/media/62cd75cdd3bf7f3008697add/Boeing_737-8K5_G-TAWY_08-22.pdf
  6. Sun, J., Hoekstra, J. M., Ellerbroek, J.「OpenAP: An Open-Source Aircraft Performance Model for Air Transportation Studies and Simulations」『Aerospace』7(8), 104, MDPI, 2020. https://doi.org/10.3390/aerospace7080104
  7. Bartel, M., Young, T. M.「Simplified Thrust and Fuel Consumption Models for Modern Two-Shaft Turbofan Engines」『Journal of Aircraft』45(4), 1450–1456, AIAA, 2008. https://doi.org/10.2514/1.35589
  8. NOAA, NASA, U.S. Air Force「U.S. Standard Atmosphere, 1976」NASA-TM-X-74335, 1976. https://ntrs.nasa.gov/citations/19770009539

注

  1. 参考文献1。 ↩ ↩2

  2. 参考文献2の図3.3.29・3.3.30(エンジンCFM56-7B24/-7B26/-7B27、推力26,000 lbf)から読んだ値。図はフラップをいちばん有利な角度にしたときのもので、資料には「DO NOT USE FOR DISPATCH」とある。 ↩ ↩2

  3. 参考文献2。エンジン1基の推力26,000 lbfを115.7 kNとして2倍した。 ↩

  4. 参考文献5の注にある、ボーイングの訓練の手引き(Flight Crew Training Manual)の値。 ↩

  5. 参考文献6の737-800の値。主翼の面積124.6 m²も同じ。 ↩

  6. 参考文献3の§25.107(V1・VR・V2)・§25.109(止まるまでの距離)・§25.111(35 ftまでにV2に達すること)・§25.113(離陸距離)。 ↩

  7. 参考文献4。 ↩

  8. 参考文献5。 ↩

プログラム
プログラム

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

このシミュレーションは、1/120秒ごとに「機体にはたらく力を計算する → 速さ・位置・角度を少しだけ進める」をくり返している。地上にいるあいだは前後の動きだけを、浮いてからは前後と上下の両方を解く。V1の速さと必要な滑走路の長さは、条件を変えるたびに同じ計算を何十回も先回りして走らせて求めている。

737-800の数字と単位

長さはm、速さはm/s、力はN、角度はラジアンで計算し、画面に出すときだけktやkm/h、度に直す。翼面積・最大離陸重量・エンジンの推力などは、メーカーの空港計画の資料と、公開されている航空機の性能のデータから取った。

定数と単位(src/sims/takeoff.ts の 25〜54 行)
const G = 9.80665; // 重力加速度 m/s²const R_AIR = 287.05; // 空気の気体定数 J/(kg·K)const P0 = 101325, T0 = 288.15, RHO0 = 1.225; // 海面の標準大気: 気圧 Pa・気温 K・密度 kg/m³const KT = 0.514444; // 1 kt(ノット)= 0.5144 m/sconst FT = 0.3048; // 1 ft = 0.3048 mconst DEG = Math.PI / 180;const S_W = 124.6; // 翼面積 S(m²)const B_W = 34.32; // 翼幅 b(m)const CD0 = 0.019, K_IND = 0.042, CD_GEAR = 0.017; // 抗力の係数: 形による分・揚力による分の係数 k・脚を出した分const T_ENG = 26000 * 4.44822; // エンジン 1 基の静止推力 N(26,000 lbf)const BPR = 5.1; // バイパス比(エンジンの外側を流れる空気と、中を通る空気の比)const MTOW = 79.0; // 最大離陸重量 tconst A_CL = 4.7; // 迎え角 1 rad あたりの揚力係数の増え方const MU_ROLL = 0.02, MU_BRAKE = 0.4; // 転がるとき・ブレーキをかけたときの摩擦の係数 μconst TAIL_X = 16.5, TAIL_Y = 3.21; // 胴体の後ろの下の点(主脚の接地点から後ろへ 16.5 m・上へ 3.21 m)。ここが地面に触れる角度が 11°const TAIL_ANG = Math.atan2(TAIL_Y, TAIL_X), TAIL_R = Math.hypot(TAIL_X, TAIL_Y);const WING_Y = 2.0; // 地上での主翼の高さ m(地面効果に使う)const RWY = 2500; // 滑走路の長さ m(羽田空港の B・D 滑走路と同じ)const SCREEN = 35 * FT; // 離陸距離の終わりの高さ 35 ft = 10.67 mconst ROT_RATE = 2.5; // 自動の操縦で機首を上げる速さ °/秒const CMD_MAX = 20; // 操縦桿で決められる機首の角度の上限 ° /** フラップ(主翼の後ろの縁から出す板)の段ごとの値。clmax: 最大揚力係数・as: 失速の迎え角 °・cd: 抗力の増え分・ang: 絵の板の角度 ° */interface Flap { clmax: number; as: number; cd: number; ang: number }const FLAPS: Record<string, Flap> = {  "1": { clmax: 1.72, as: 14.0, cd: 0.006, ang: 3 },  "5": { clmax: 1.9, as: 13.5, cd: 0.012, ang: 9 },  "15": { clmax: 2.0, as: 13.0, cd: 0.03, ang: 17 },  "25": { clmax: 2.08, as: 12.5, cd: 0.055, ang: 27 },};

FLAPSの表だけは推定値である。最大揚力係数clmaxと失速の迎え角asが大きいほど遅い速さで浮けるが、抗力の増え分cdも大きくなる。値は、必要な滑走路の長さがボーイングの資料に近くなるように選んだ。TAIL_X・TAIL_Yは、主脚の接地点から見た胴体の後ろの下の点で、が「尾部が触れる機首の角度」になる。

空気・推力・揚力・抗力

本文の4つの式が、ほぼそのままの形で並んでいる。makeCondは国際標準大気の式で標高から気圧を求め、本文ので密度を出す。気温はつまみの値をそのまま使うので、暑い日ほど密度が小さい。

力の式(src/sims/takeoff.ts の 56〜97 行)
/** その日の条件: 重さ・空気(密度 ρ・音の速さ・気圧の比)・推力の割合・風 */interface Cond { m: number; W: number; rho: number; sig: number; a: number; dP: number; thr: number; hot: number; wind: number; fl: Flap } /** 国際標準大気: 標高 elev(m)の気圧を求め、その日の気温 tC(°C)で密度を決める */function makeCond(mt: number, thrPct: number, flap: string, tC: number, elev: number, windKt: number): Cond {  const Tisa = T0 - 0.0065 * elev; // 標準大気の気温は 1,000 m で 6.5 °C 下がる  const p = P0 * Math.pow(Tisa / T0, 5.25588); // 気圧  const Tk = tC + 273.15, rho = p / (R_AIR * Tk); // 気体の状態方程式 ρ = p ÷ (R T)  const over = Tk - (Tisa + 15); // 「標準 +15 °C」を超えた分  return {    m: mt * 1000, W: mt * 1000 * G, rho, sig: rho / RHO0, a: Math.sqrt(1.4 * R_AIR * Tk), dP: p / P0,    thr: thrPct / 100, hot: over > 0 ? Math.max(0.6, 1 - 0.008 * over) : 1, wind: Math.max(0, windKt) * KT, fl: FLAPS[flap] ?? FLAPS["5"],  };} /** 静止推力に対する割合(Bartel と Young の式)。速くなるほど(マッハ数 M)、空気が薄いほど(気圧の比 dP)減る */function thrustRatio(M: number, dP: number) {  const G0 = 0.0606 * BPR + 0.6337;  const A = -0.4327 * dP * dP + 1.3855 * dP + 0.0472;  const Z = 0.9106 * dP * dP * dP - 1.7736 * dP * dP + 1.8697 * dP;  const X = 0.1377 * dP * dP * dP - 0.4374 * dP * dP + 1.3003 * dP;  return A - ((0.377 * (1 + BPR)) / Math.sqrt((1 + 0.82 * BPR) * G0)) * Z * M + (0.23 + 0.19 * Math.sqrt(BPR)) * X * M * M;} /** 揚力係数 C_L: 失速の迎え角まではまっすぐ増え、その先は下がる(失速) */function clOf(f: Flap, alpha: number) {  const as = f.as * DEG, cl0 = f.clmax - A_CL * as;  if (alpha <= as) return cl0 + A_CL * alpha;  return Math.max(f.clmax * 0.62, f.clmax - 3.2 * (alpha - as));} interface Air { L: number; D: number; CL: number }/** 揚力 L = ½ρV²S·C_L と、抗力 D = ½ρV²S(C_D0 + ΔC_D + φ·k·C_L²)。h は主脚の高さ(地面効果)、gear は脚の出ている割合 */function aero(c: Cond, V: number, alpha: number, h: number, gear: number, oneOut: boolean, spoil: boolean, out: Air) {  const q = 0.5 * c.rho * V * V; // 動圧 ½ρV²  let CL = clOf(c.fl, alpha);  if (spoil) CL *= 0.2; // 中止のときは翼の上の板(スポイラー)を立てて揚力を捨て、車輪に重さをかける  const r = (16 * (WING_Y + Math.max(0, h))) / B_W, phi = (r * r) / (1 + r * r); // 地面効果: 地面に近いほど φ が小さい  const over = alpha - c.fl.as * DEG;  const CD = CD0 + c.fl.cd + CD_GEAR * gear + phi * K_IND * CL * CL + (oneOut ? 0.003 : 0) + (spoil ? 0.05 : 0) + (over > 0 ? 0.6 * over : 0);  out.L = q * S_W * CL; out.D = q * S_W * CD; out.CL = CL;}

thrustRatioは、エンジンの推力が速さ(マッハ数)と気圧でどれだけ落ちるかを表す近似式である。海面で止まっているときに1で、離陸の速さ(マッハ0.23ほど)では0.8ほどまで下がる。aeroの中のqは、phiは地面効果の係数で、翼が地面に近いほど小さく、誘導抗力を減らす。迎え角が失速の角度を超えると、clOfが返すは下がり、抗力は増える。

運動方程式を少しずつ進める

advanceが物理の中心である。地上では、地面が車輪を押し返す力N = を求め、これが0以下になったら浮いたとみなす。そうでなければ、本文ので速さを、その速さで位置を進める。

運動の計算(src/sims/takeoff.ts の 99〜163 行)
/** 機体の状態。x: 滑走の始まりからの距離 m・h: 主脚の高さ m・V: 対気速度 m/s・gam: 経路角 γ・th: 機首の角度 θ・cmd: 操縦桿で決めた θ */interface Mo {  x: number; h: number; V: number; gam: number; th: number; q: number; cmd: number; ground: boolean; t: number;  sp: [number, number]; out: [boolean, boolean]; reject: boolean; gear: number; lofT: number; tail: number; stall: boolean;  crashed: boolean; stopped: boolean; T: number; D: number; L: number; N: number; F: number; CL: number; alpha: number;}function newMo(c: Cond): Mo {  return { x: 0, h: 0, V: c.wind, gam: 0, th: 0, q: 0, cmd: 0, ground: true, t: 0, sp: [0.35, 0.35], out: [false, false], reject: false, gear: 1, lofT: -1, tail: 0, stall: false, crashed: false, stopped: false, T: 0, D: 0, L: 0, N: 0, F: 0, CL: 0, alpha: 0 };}const AIR: Air = { L: 0, D: 0, CL: 0 }; /** dt 秒進める。地上では m·dv/dt = T cosθ − D − μN(N = W − L − T sinθ)、N が 0 以下になったら浮く。 *  空中では m·dV/dt = T cos α − D − W sin γ と m·V·dγ/dt = L + T sin α − W cos γ(α = θ − γ) */function advance(c: Cond, mo: Mo, dt: number) {  if (mo.crashed || mo.stopped) return;  mo.t += dt;  // エンジンの出力は、目標へ時定数 1.5 秒で近づく(止まったエンジンは 1 秒で落ちる。中止のときは絞る)  for (let e = 0; e < 2; e++) {    const target = mo.out[e] ? 0 : mo.reject ? 0.06 : 1, tau = mo.out[e] ? 1 : 1.5;    mo.sp[e] += (target - mo.sp[e]) * (1 - Math.exp(-dt / tau));  }  const T = (mo.sp[0] + mo.sp[1]) * T_ENG * c.thr * c.hot * thrustRatio(mo.V / c.a, c.dP);  // 機首の角度: 昇降舵の効きは計器の速さで決まり、80 kt より遅いと機首は上がらない  const ve = (mo.V * Math.sqrt(c.sig)) / KT;  const eff = mo.ground ? clamp((ve - 80) / 50, 0, 1) : 1;  const want = clamp((mo.cmd - mo.th) / 0.4, -4 * DEG, 6 * DEG * eff);  mo.q += (want - mo.q) * (1 - Math.exp(-dt / 0.25));  mo.th += mo.q * dt;  if (mo.ground && mo.th < 0) { mo.th = 0; mo.q = Math.max(0, mo.q); }  // 尾部: 主脚の接地点のまわりに機体が回るので、尾部の下の点が地面より下へは行かない  const lim = TAIL_ANG + Math.asin(clamp(mo.h / TAIL_R, 0, 1));  if (mo.th > lim) { mo.th = lim; mo.q = Math.min(mo.q, 0); mo.tail += dt; }  const alpha = mo.th - mo.gam;  aero(c, mo.V, alpha, mo.h, mo.gear, mo.out[0] || mo.out[1], mo.reject && mo.ground, AIR);  const L = AIR.L, D = AIR.D;  mo.T = T; mo.L = L; mo.D = D; mo.CL = AIR.CL; mo.alpha = alpha;  if (mo.ground) {    const N = c.W - L - T * Math.sin(mo.th); // 地面が車輪を押し返す力    if (N <= 0 && !mo.reject) { mo.ground = false; if (mo.lofT < 0) mo.lofT = mo.t; }    else {      const F = (mo.reject ? MU_BRAKE : MU_ROLL) * Math.max(0, N);      mo.N = Math.max(0, N); mo.F = F;      let acc = (T * Math.cos(mo.th) - D - F) / c.m;      if (mo.V - c.wind <= 0.01 && acc < 0) { acc = 0; mo.F = Math.max(0, T * Math.cos(mo.th) - D); if (mo.reject) mo.stopped = true; } // 止まっているときの摩擦は、押す力とつり合う分だけ      mo.V += acc * dt;      if (mo.V - c.wind < 0) { mo.V = c.wind; if (mo.reject) mo.stopped = true; }      mo.x += (mo.V - c.wind) * dt; // 地面に対する速さ = 対気速度 − 向かい風      return;    }  }  if (alpha > c.fl.as * DEG) mo.stall = true;  const accV = (T * Math.cos(alpha) - D) / c.m - G * Math.sin(mo.gam);  const accG = ((L + T * Math.sin(alpha)) / c.m - G * Math.cos(mo.gam)) / Math.max(mo.V, 20);  mo.V = Math.max(1, mo.V + accV * dt);  mo.gam += accG * dt;  mo.x += (mo.V * Math.cos(mo.gam) - c.wind) * dt;  mo.h += mo.V * Math.sin(mo.gam) * dt;  mo.N = 0; mo.F = 0;  if (mo.h > 6 && mo.t - mo.lofT > 2) mo.gear = Math.max(0, mo.gear - dt / 7); // 上がり始めたら脚をしまう(7 秒)  if (mo.h <= 0) {    const sink = -mo.V * Math.sin(mo.gam); // 地面に当たる速さ    mo.h = 0; mo.gam = 0;    if (sink > 3 || mo.gear < 0.5) mo.crashed = true; else mo.ground = true;  }}

浮いたあとは、進む向きの式と、向きが曲がる式の2本を解く。は進む向きが水平となす角(経路角)で、迎え角はになる。

計算は「今の力で速さを進め、新しい速さで位置を進める」順の、いちばん単純な方法(半陰的オイラー法)で、刻みを1/120秒と細かくして誤差を抑えている。刻みは画面のコマ数に関係なく一定なので、速い端末でも遅い端末でも同じ離陸になる。機首の角度は、操縦桿で決めた角度cmdへ追いかける。上げる速さの上限は、計器の速さが80 ktから130 ktに上がるにつれて大きくなる(昇降舵が効くようになる)。尾部の下の点が地面より下へ行く角度になったら、そこで止めて「尾部が触れた」と数える。

V1と必要な滑走路を、先回りして求める

autoPilotは自動の操縦で、VRで毎秒2.5°ずつ機首を上げ、浮いたあとは速さがV2 + 10 kt(片方のエンジンが止まったらV2)になるように機首の角度を加減する。trialは、この操縦で離陸を1回、画面には出さずに走らせ、距離を返す。

速さの印(src/sims/takeoff.ts の 165〜223 行)
/** 速さの印(計器の速さ m/s)と、必要な滑走路の長さ m。vs: 失速速度・vr: 機首を上げる速さ・v2: 安全に上昇できる速さ・v1: 離陸をやめられる最後の速さ */interface Plan { vs: number; vr: number; v2: number; v1: number; tod: number; todOei: number; asd: number; need: number } /** 自動の操縦: rotAt(計器の速さ)で 2.5°/秒で機首を上げ、浮いたら速さを V2 + 10 kt(片方が止まったら V2)に保つ。失速の手前では機首を下げる */function autoPilot(c: Cond, p: Plan, mo: Mo, dt: number, rotAt: number) {  if (mo.reject) { mo.cmd = 0; return; }  const ve = mo.V * Math.sqrt(c.sig), oei = mo.out[0] || mo.out[1], top = (oei ? 12.5 : 15) * DEG;  if (mo.lofT < 0 || mo.h < 3) {    if (ve >= rotAt || mo.cmd > 0) mo.cmd = Math.min(mo.cmd + ROT_RATE * DEG * dt, top);  } else {    const tgt = oei ? p.v2 : p.v2 + 10 * KT;    mo.cmd = clamp(mo.cmd + clamp((ve - tgt) * 0.25 * DEG, -2 * DEG, 2 * DEG) * dt, 2 * DEG, 18 * DEG);  }  if (!mo.ground) mo.cmd = Math.min(mo.cmd, mo.gam + (c.fl.as - 2) * DEG);} const PLAN_DT = 1 / 20;/** 決まった操縦で 1 回走らせる。aeo: 全部のエンジン・go: 速さ vef で 1 基止まり離陸を続ける・stop: 1 基止まり 1 秒後に中止する */function trial(c: Cond, p: Plan, mode: "aeo" | "go" | "stop", vef: number) {  const mo = newMo(c), sq = Math.sqrt(c.sig);  let tFail = -1, v1 = 0;  for (let i = 0; i < 4000; i++) {    const ve = mo.V * sq;    if (mode !== "aeo" && tFail < 0 && ve >= vef) { mo.out[0] = true; tFail = mo.t; }    if (mode === "stop" && tFail >= 0 && !mo.reject && mo.t - tFail >= 1) {      v1 = ve; mo.reject = true;      mo.x += 2 * (mo.V - c.wind); // 14 CFR 25.109: 中止を始めた速さで 2 秒走る分を足す    }    autoPilot(c, p, mo, PLAN_DT, p.vr);    advance(c, mo, PLAN_DT);    if (mode === "stop" && mo.stopped) return { dist: mo.x, v1 };    if (mode !== "stop" && mo.h >= SCREEN) return { dist: mo.x, v1 };    if (mo.crashed || mo.x > 7000) break;  }  return { dist: 9999, v1 };} /** 速さの印を決める。VS は最大揚力係数から、VR = 1.08 VS・V2 = 1.13 VS。 *  V1 は「中止して止まる距離」と「片方のエンジンで続けて 35 ft に届く距離」が等しくなる速さを二分法で探す */function makePlan(c: Cond): Plan {  const vs = Math.sqrt((2 * c.W) / (RHO0 * S_W * c.fl.clmax));  const p: Plan = { vs, vr: 1.08 * vs, v2: 1.13 * vs, v1: 0, tod: 0, todOei: 0, asd: 0, need: 0 };  p.tod = trial(c, p, "aeo", 0).dist;  const both = (vef: number) => ({ s: trial(c, p, "stop", vef), g: trial(c, p, "go", vef) });  let lo = 0.6 * p.vr, hi = p.vr, A = both(lo), Bv = both(hi), pick = A;  if (A.s.dist >= A.g.dist) pick = A;  else if (Bv.s.dist <= Bv.g.dist) pick = Bv;  else {    for (let i = 0; i < 9; i++) {      const mid = (lo + hi) / 2, M = both(mid);      if (M.s.dist < M.g.dist) { lo = mid; A = M; } else { hi = mid; Bv = M; }    }    pick = Math.max(A.s.dist, A.g.dist) < Math.max(Bv.s.dist, Bv.g.dist) ? A : Bv;  }  p.v1 = Math.min(pick.s.v1 || hi, p.vr);  p.asd = pick.s.dist; p.todOei = pick.g.dist;  p.need = Math.max(1.15 * p.tod, p.asd, p.todOei); // 14 CFR 25.113: 全部のエンジンの距離の 1.15 倍と、片方が止まったときの長いほう  return p;}

makePlanは、まず失速速度からVRとV2を決める。次に、エンジンが止まる速さを変えながら「止まる距離」と「続ける距離」を比べ、2つが等しくなるところを二分法で探す。区間を半分に切ることを9回くり返すので、V1は0.1 ktほどの細かさで決まる。この先回りの計算は1回に20回あまり離陸を走らせるため、刻みは1/20秒と粗くしている。必要な滑走路の長さは、全部のエンジンで35 ftに届く距離の1.15倍と、止まる距離・続ける距離のうち、いちばん長いものである。

1刻みごとの判断と知らせ

tickは、1刻みごとに操縦(キー・自動の操縦)を反映し、物理を進めてから、V1・VR・浮いた・35 ft・尾部の接地・失速を一度だけ知らせる。エンジンが止まったときは、自動の操縦なら1秒後(操縦士が気づいて動くまでの時間)の速さがV1より前かどうかで、やめるか続けるかを決める。

刻みと知らせ(src/sims/takeoff.ts の 240〜293 行)
function restart(api: Api, s: S) {  s.c = makeCond(api.num("mass"), api.num("thrust"), api.str("flap"), api.num("temp"), api.num("elev"), api.num("wind"));  s.p = makePlan(s.c); s.mo = newMo(s.c);  s.failT = -1; s.decided = false; s.ev = { v1: -1, vr: -1, lof: -1, s35: -1 }; s.pathN = 0; s.done = ""; s.flags = {}; s.wait = 0;} function failEngine(api: Api, s: S) {  if (s.mo.out[0] || s.done) return;  s.mo.out[0] = true; s.failT = s.mo.t; s.decided = false;  api.note(api.bool("auto") ? "エンジン 1 が止まった。1 秒後に、続けるかやめるかを決める" : "エンジン 1 が止まった。V1 より前なら「離陸をやめる」、後なら続ける", 3000);}function reject(api: Api, s: S, msg: string) {  if (!s.mo.ground || s.mo.reject) return;  s.mo.reject = true; s.mo.cmd = 0; api.note(msg, 2600);}function toManual(api: Api, s: S) {  if (!api.bool("auto")) return;  api.set("auto", false);  if (!s.flags.manual) { s.flags.manual = true; api.note("手で操縦: 取っ手の高さが機首の角度の目標です", 2400); }} /** 物理の 1 刻み + 起きたことの知らせ */function tick(api: Api, s: S, h: number) {  const mo = s.mo, c = s.c, p = s.p;  if (s.done) return;  const sq = Math.sqrt(c.sig);  if (s.keyDn) mo.cmd = Math.min(CMD_MAX * DEG, mo.cmd + 3 * DEG * h);  if (s.keyUp) mo.cmd = Math.max(0, mo.cmd - 3 * DEG * h);  if (api.bool("auto")) autoPilot(c, p, mo, h, p.vr + s.rotOff * KT);  if (!Number.isNaN(s.failAt) && s.failT < 0 && mo.V * sq >= p.v1 + s.failAt * KT) failEngine(api, s);  if (s.failT >= 0 && !s.decided && mo.t - s.failT >= 1 && api.bool("auto")) {    s.decided = true;    if (mo.ground && mo.V * sq < p.v1) reject(api, s, "V1 より前なので、離陸をやめてブレーキをかける");    else if (mo.ground || mo.h < 30) api.note("V1 を過ぎているので、片方のエンジンで離陸を続ける", 2600);  }  advance(c, mo, h);  const ve = mo.V * sq;  if (s.ev.v1 < 0 && ve >= p.v1 && !mo.reject) { s.ev.v1 = mo.x; if (!s.mo.out[0]) api.note("V1: ここから先は、エンジンが止まっても離陸を続ける", 1600); }  if (s.ev.vr < 0 && ve >= p.vr && !mo.reject) { s.ev.vr = mo.x; api.note(api.bool("auto") ? "VR: 機首を上げる" : "VR: 取っ手を下へ引いて機首を上げる", 2000); }  if (s.ev.lof < 0 && mo.lofT >= 0) { s.ev.lof = mo.x; api.note("浮いた: 揚力 L と推力の上向きの分が、重さ W を超えた", 2200); }  if (s.ev.s35 < 0 && mo.h >= SCREEN) { s.ev.s35 = mo.x; api.note(`高さ 35 ft(10.7 m)を通過。ここまでの距離 ${km(mo.x)} m`, 2600); }  if (mo.tail > 0 && !s.flags.tail) { s.flags.tail = true; api.note("尾部が滑走路に触れた: 浮く前に機首の角度が 11° に届いた", 3000); }  if (mo.stall && !s.flags.stall) { s.flags.stall = true; api.note("失速: 迎え角が大きすぎて揚力が減った", 2600); }  if (!mo.ground && mo.x > RWY && mo.h < SCREEN && !s.flags.short) { s.flags.short = true; api.note("滑走路の端で、まだ 35 ft に届いていない", 2600); }  // 経路を記録(上の帯の図に描く)  const n = s.pathN;  if (n < PATH_N && (n === 0 || mo.x - s.path[(n - 1) * 2] > 8 || Math.abs(mo.h - s.path[(n - 1) * 2 + 1]) > 2)) { s.path[n * 2] = mo.x; s.path[n * 2 + 1] = mo.h; s.pathN++; }  // 終わり  if (mo.crashed) { s.done = "crash"; api.note(mo.stall ? "失速して、地面に強く当たった" : "地面に強く当たった", 3000); }  else if (mo.ground && mo.x > RWY) { s.done = "over"; api.note("滑走路の端を越えた", 3000); }  else if (mo.stopped) { s.done = "stop"; api.note(`止まった。滑走路の残り ${km(RWY - mo.x)} m`, 3000); }  else if (mo.h >= 400 * FT) s.done = "ok";  else if (mo.t > 240) s.done = "time";}

描く

描画面の幅と高さから、上の帯(滑走路の全体)・速度計・操縦桿・機体の位置を毎コマ決めるので、横長の画面でも縦長のスマホでも配置が崩れない。機体の形は主脚の接地点を原点にした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" });}

力の矢印は、重さを決まった長さlwにして、ほかの力をとの比で描く。そのため、推力は重さの3割ほど、抗力や摩擦はもっと短く、本物の比率のまま見える。速度計の斜線は失速速度より遅い範囲で、V1・VR・V2の三角の印はmakePlanの結果である。

操縦桿・キー・画面のボタン

取っ手をつかむと、指の高さがそのまま機首の角度の目標になる(上端が0°、下端が20°)。取っ手以外の場所を縦になぞったときは、動かした量だけ目標を変える。どちらも、触った時点で自動の操縦を切る。

操作(src/sims/takeoff.ts の 698〜727 行)
change(key, api, s) {  if (PHYS.includes(key)) s.wait = 0.25; // つまみを動かしている間は待ち、止まってから計算し直して最初から走らせる},pointer(type, api, s) {  const P = api.pointer, L = s.lay;  if (!L) return;  const toCmd = (y: number) => clamp((y - L.hy0) / (L.hy1 - L.hy0), 0, 1) * CMD_MAX * DEG;  if (type === "down") {    for (const b of s.btn) if (P.x >= b.x && P.x <= b.x + b.w && P.y >= b.y && P.y <= b.y + b.h) {      if (b.act === "fail") failEngine(api, s);      else if (b.act === "reject") { toManual(api, s); reject(api, s, "離陸をやめてブレーキをかける"); }      else { const ro = s.rotOff, fa = s.failAt; restart(api, s); s.rotOff = ro; s.failAt = fa; }      return;    }    const onHandle = Math.abs(P.x - L.hx) < L.hs && P.y > L.hy0 - L.hs && P.y < L.hy1 + L.hs;    s.grab = { mode: onHandle ? "handle" : "free", y0: P.y, c0: s.mo.cmd, moved: false };    if (onHandle) { toManual(api, s); s.mo.cmd = toCmd(P.y); s.grab.moved = true; }  } else if (type === "move" && s.grab && P.down) {    const g = s.grab;    if (!g.moved && Math.abs(P.y - g.y0) > 4) { g.moved = true; toManual(api, s); }    if (!g.moved) return;    s.mo.cmd = g.mode === "handle" ? toCmd(P.y) : clamp(g.c0 + ((P.y - g.y0) / (L.hy1 - L.hy0)) * CMD_MAX * DEG, 0, CMD_MAX * DEG);  } else if (type === "up" || type === "cancel") s.grab = null;},key(type, code, api, s) {  const on = type === "down";  if (code === "Space" || code === "ArrowDown") { s.keyDn = on; if (on) toManual(api, s); }  else if (code === "ArrowUp") { s.keyUp = on; if (on) toManual(api, s); }  else if (code === "KeyE" && on) failEngine(api, s);},

Spaceキーと下向きの矢印キーは押している間だけ毎秒3°ずつ目標を上げ、上向きの矢印キーで下げる。Eキーは、画面の「エンジン1を止める」と同じ働きをする。

翼のまわりの流れ(小さなシミュレーション)

本文の翼の図は、円柱のまわりの流れを、ジューコフスキー変換で翼の形のまわりへ写して描いている。円柱のまわりの流れは、一様な流れ・二重湧き出し・渦(循環)を足し合わせた式で表せる。円の中心を少しずらすと、写した形が丸い前縁ととがった後縁をもつ翼になる。

流れの式(src/sims/takeoff-wing.ts の 27〜47 行)
/** 翼の向きの座標で点 z の流れを求める(一様な流れの速さ U = 1)。out = [流れ関数 ψ, u, v, 翼の中なら 1] */function flowAt(f: Foil, a: number, G: number, zx: number, zy: number, out: Float64Array) {  // 写像を逆にたどる: ζ = (z ± √(z² − 4c²)) ÷ 2 のうち、円の外にあるほう  const ax = zx * zx - zy * zy - 4 * C * C, ay = 2 * zx * zy, r = Math.hypot(ax, ay);  const sx = Math.sqrt((r + ax) / 2), sy = (ay < 0 ? -1 : 1) * Math.sqrt(Math.max(0, (r - ax) / 2));  let px = (zx + sx) / 2, py = (zy + sy) / 2;  if (Math.hypot(px - f.mx, py - f.my) < f.R) { px = (zx - sx) / 2; py = (zy - sy) / 2; }  const qx = px - f.mx, qy = py - f.my, q2 = qx * qx + qy * qy; // ζ' = ζ − μ  if (q2 < f.R * f.R * 0.995) { out[3] = 1; return; }  out[3] = 0;  const ca = Math.cos(a), sa = Math.sin(a), R2 = f.R * f.R, g = G / (2 * Math.PI);  // 複素速度ポテンシャル w = ζ' e^(−iα) + R² e^(iα) / ζ' + (iΓ/2π) ln ζ' の虚部が流れ関数 ψ  out[0] = qy * ca - qx * sa + (R2 * (sa * qx - ca * qy)) / q2 + g * 0.5 * Math.log(q2);  // dw/dζ = e^(−iα) − R² e^(iα) / ζ'² + iΓ / (2π ζ')  const q4 = q2 * q2, ix = (qx * qx - qy * qy) / q4, iy = (-2 * qx * qy) / q4;  const dx = ca - R2 * (ca * ix - sa * iy) + (g * qy) / q2, dy = -sa - R2 * (ca * iy + sa * ix) + (g * qx) / q2;  // dz/dζ = 1 − c²/ζ²。dw/dz = (dw/dζ) ÷ (dz/dζ) = u − iv  const p2 = px * px + py * py, p4 = p2 * p2, jx = 1 - (C * C * (px * px - py * py)) / p4, jy = (2 * C * C * px * py) / p4;  const jj = jx * jx + jy * jy + 1e-6;  out[1] = (dx * jx + dy * jy) / jj; out[2] = -(dy * jx - dx * jy) / jj;}

循環は、後ろの縁で流れがなめらかに離れる条件(クッタの条件)からと決まり、揚力は幅1 mあたりになる。網点の濃さは圧力係数が低い所ほど濃くし、流線は流れ関数が等しい点を結んで描く。粘りを入れていないので、迎え角を大きくしても流れははがれず、揚力は増え続ける。

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

プログラム全体を見る(src/sims/takeoff.ts・737 行)

読み込んでいます

プログラム全体を見る(src/sims/takeoff-wing.ts・202 行)

読み込んでいます

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

姉妹サイト

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