滑り台で体に掛かるGは、谷の底と着地で跳ね上がる

まっすぐな坂を滑っている間、体に掛かるGは1より小さい。大きくなるのは、坂が谷へ曲がる所と、浮いた体が面に戻る着地の瞬間である。

記事

滑り台で体に掛かるGは、谷の底と着地で跳ね上がる

まっすぐな坂を滑っている間、体に掛かるGは1より小さい。大きくなるのは、坂が谷へ曲がる所と、浮いた体が面に戻る着地の瞬間である。

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

現象

30 mの滑り台で、けがをしたのは大人だった

2025年4月25日、北九州市にある皿倉山の山頂近くで、長さ約30 mの滑り台の利用が始まった。滑る面は超高分子量ポリエチレンという樹脂の板で、主な傾きは22°。6〜12歳の子ども向けに作られた遊具である1。ところが、大人が着地でけがをしたという通報を受け、市は6月3日に利用を止めた。7月4日までに届いたけがの通報は、市の職員を除いて9件。6〜12歳の子どもには、けが人が出ていない2。

市が開いた検討会議の資料には、けがをした人から聞き取った話が並ぶ。滑り始めからスピードが出て怖かった。滑った姿勢のまま飛び出し、マットを越えて地面に尻もちをついた。医師の1人は、けがの起き方を「基本的にはスピード」とまとめている。ただ、なぜ大人だけがけがをしたのかについては、専門家の意見が分かれている。「大人は体重が重い分、空気抵抗の影響を受けづらいので、スピードが出る」という見方と、「体重はスピードにはあまり関係がない。体重で変わるのは衝撃度」という見方である3。

どちらが当たっているかは、摩擦と空気の抵抗がどれだけ効くかを比べると見えてくる。画面の滑り台は、四角の取っ手をドラッグすると形が変わる。滑る人の体に掛かるGは、滑り台の線の太さと、その下に打った網点の濃さで分かる。まっすぐな坂では線は細いまま。太くなるのは谷の底と、浮いた体が面に戻る所だ。

仕組み

体に掛かるGは、面が押し返す力で決まる

椅子に座っていると、お尻は体重と同じ大きさの力で押し返されている。面が体を垂直に押すこの力を、垂直抗力と呼ぶ。体に掛かるGはが体重の何倍かを表す数で、を質量(kg)と重力加速度(9.8 m/s²)の積で割って求める。

止まって座っていればGは1である。体重計に乗ったまま滑ったとすると、その目盛りが体重の何倍を指すかがGにあたる。

まっすぐな坂では、重さの一部が坂に沿って体を引き、残りが面を押す。傾きをとするとで、22°の坂ならGは0.93。坂を下る間、体はわずかに軽くなっている。

Gが1を超えるのは、滑る道すじが曲がる所だ。谷の底では、体はまっすぐ進もうとするのに、面がそれを上向きに曲げる。面が体を押す力は、曲げるのに要る分だけ大きい。曲がりを半径(m)の円とみなすと、速さ(m/s)で通るときの垂直抗力は次の式になる。

山の頂では曲がる向きが逆なので、の前の符号はマイナスだ。シミュレーションの「落ち込みと浮き上がり」で、半径2.8 mの谷の底を通るときの速さは時速33 km(秒速9.1 m)。は約29 m/s²とのおよそ3倍になり、Gは4.0に達する。

同じ速さvでも、曲がりの半径Rが小さいほどNは大きく変わる R R mg 谷の底 N = m(g cos θ + v²/R) Gは1より大きい mg 山の頂 N = m(g cos θ − v²/R) Gは1より小さい v²/Rがg cos θを超えると、面を離れて飛ぶ(G = 0) まっすぐな坂: N = mg cos θ(Gは1より小さい)
谷の底では面が強く押し、山の頂では押す力が弱まる。v²/Rがg cos θを超えると体は浮く

山の頂でがより大きくなると、は0を下回る計算になる。面は体を押すことはできるが、引き寄せることはできない。体はそこで面を離れ、放物線を描いて飛ぶ。飛んでいる間のGは0だ。

負担が大きいのは、面に戻るときである。面に垂直な向きの速さを、体と面がつぶれるわずかな長さのうちに止めなければならない。ばねで受け止めたとみなすと、いちばん大きい加速度は次の式で表せる。

シミュレーションではを10 cmとした。秒速1.6 mで面に当たればは約26 m/s²で、重さの分を足したGは3.6。出口から28 cm下のマットへ落ちるだけでもは秒速2.3 mに達し、Gは6.6まで上がる。は姿勢やマットの硬さで大きく変わるので、この値は目安である。

仕組み

摩擦係数が同じなら、体重が速さに効くのは空気の抵抗だけ

坂に沿った向きでは、重さの分力が体を前へ押し、摩擦と空気の抵抗が後ろへ引く。動摩擦係数を、空気の密度を(1.2 kg/m³)、抵抗係数と前から見た面積の積を(m²)とすると、運動の式はこうなる。

まっすぐな坂でを入れると、空気の抵抗を表す項を除いて、どの項にもが掛かっている。摩擦だけなら、重い人も軽い人も同じ加速度で滑るということだ。速くなるか遅くなるかの境目は、重さの分力と摩擦がつり合う傾きで、

となる。が0.3なら16.7°だ。これより急な所では速くなるが、緩い所では遅くなる。出口の手前にある平らな部分(減速部)は、この性質を使って体を止めている。ただしは一定ではない。服の生地やぬれ具合で大きく変わる。皿倉山で利用を再開したときの看板にも、「着ているもので 滑る速さが 変わります」とある1。シミュレーションのは、その幅を試すために置いた仮の値である。

空気の抵抗だけは、体重によって効き方が違う。抵抗の力は速さの2乗と前から見た面積に比例し、体重とは関係がない。体の形が同じなら、面積は体重の2/3乗でしか増えないので、抵抗で減る加速度は、体重が4.5倍になると約0.6倍で済む。摩擦係数が同じなら、重い人ほど速く滑るのはこの分だけである。

もっとも、空気の抵抗が効くのは速いときに限られる。秒速1 mなら、抵抗は秒速8 mのときの64分の1しかない。立教大学の村田次郎さんたちは、児童遊園の滑り台で重さの違う段ボール箱を滑らせ、空気の抵抗がほとんど効いていないことを確かめた。そのうえで、ステンレス板の滑り台では重さによる速さの違いが見られず、ローラー式では重い箱ほど速いという結果を得ている。ローラー式で差が出たのは空気のためではなく、ローラーの抵抗が速さと重さで変わるためと考えられている4。

らせんでは、横向きの力が1つ加わる。上から見て半径で回るには、横向きにの加速度が要る。その力を出すのも溝の壁だ。体は溝の中で外側へずり上がり、縦と横の力を合わせた向きに傾いて落ち着く。傾きの角度は次の式で決まる。

壁が押す力が増えれば摩擦も増えるので、らせんでは速くなるほどブレーキが強くかかる。速さはやがて、ある値から上がらなくなる。

上から見た形 R_h v²/R_h 進む向き 横向きにv²/R_hの加速度が要る 溝の断面(進む向きから見る) 外側 φ N mg tan φ = (v²/R_h) ÷ (g cos θ)
らせんを上から見た形と、溝の中で体が外へ傾く角度φ
試す

形を描き変えて、Gが大きくなる所を探す

上の段が滑り台の全体で、四角の取っ手を動かすと形が変わり、「+」をたたくと点が増える。滑り台の上をたたくと、そこから滑り出す。中の段は水平の位置ごとに見たG(点線は先回りの計算、太線は今の滑り)、下の段は人のまわりを拡大した図と、高さから生まれたエネルギーの行き先である。

  1. 斜面に沿った長さ50 mあまりの滑り台を、体重60 kg、 = 0.2で滑る。Gは0.83〜1.19のあいだで、ほとんどの区間で1を下回る。止まるのは12秒ほどあとで、出口手前の平らな所。を0.3にすると、傾きが16.7°より緩くなる25 m付近で止まってしまう。
  2. 高さ8.5 mから落ち込み、谷を抜けて小さな山を越える。谷の底で時速33 km、Gは4.0。山の頂ではが0を下回って体が0.5秒ほど浮き、3.6 Gで着地する。を0.25にすると、谷を抜ける速さが落ち、頂のGは0.09まで下がるものの、浮かずに越える。
  3. 上から見て半径2 mで回るらせん。速さは時速13 kmほどで落ち着き、横向きのGは0.6、体は溝の中で32°外へ傾く。右下の「上から見た形」の取っ手で半径を1.2 mにすると、速さは時速10 kmまで下がるのに、横向きのGは0.71に上がり、傾きは37°になる。小さく回るほど横向きの力が大きく、摩擦が強く効くからだ。
  4. 水を流したウォータースライダーで、を0.04にした。速さは時速40 kmを超える。谷ではGが2.7まで上がるが、こぶの頂では0まで下がって体が2度浮く。最後はプールへ。
  5. 22°の坂を持つ長さ28 mほどの滑り台(形は皿倉山の実物とは違う)を、よく滑る服( = 0.12)で滑る。減速部で止まりきれず、時速29 kmで出口から飛び出し、1.7 m先のマットに落ちて6.6 G。を0.3にすると、時速18 kmまでしか上がらず、減速部の中で止まる。を0.25にすると、出口を時速6 kmで越えて落ちる。着地のGは6.5とほとんど変わらない。縦向きの衝撃は落ちる高さで決まり、速さで変わるのは前へ進む勢いのほうである。
  6. 体重90 kgの大人と20 kgの子どもが、長さ60 mあまりの滑り台を並んで滑る。空気の抵抗があると、出口の速さは大人が時速21 km、子どもが18 kmで、子どもは0.37秒遅れて着く。「空気の抵抗」を切れば差は消え、2人とも時速26 kmで同時に出口へ。体重の差が速さの差になるのは、空気の抵抗が効く分だけである。
現実では

皿倉山の検討会議と、遊具・遊戯施設の決まり

皿倉山の滑り台と同じ製品は、メーカーの調べで1993年から2025年9月までに全国323基あり、そのうち105基が長さ20 m以上である。北九州市内にある同じ製品では、2023年5月、響灘緑地にある長さ38 mの滑り台で、30代の女性がスピードに乗ったまま出口で左足を着き、足首の2か所を骨折した12。一方、皿倉山で滑り方を案内した市の職員によると、子どもは滑り台の端まで行くことがほとんどない。最後の平らな減速部で止まり、滑りの悪い日は途中の小段で止まる子もいるという2。

報告書に載った医師の1人は、けがを2つの型に分けている。速いまま足をつけずにお尻から落ち、尾てい骨や背骨を折る型。着地で足をついても衝撃を吸収しきれず、ねんざや骨折に至る型である。会議は2026年1月、子どもの自由な利用は続け、夜間と大人の利用は禁止すべきだとする報告書をまとめた。大人の利用者は推計1,300〜5,200人で、けがの割合は0.1〜0.4%と見積もられた。改造の案について、遊具の業界団体は減速部を延ばしたり摩擦係数を上げたりすると、終わりの所に人がたまって別の事故が起きうると述べている3。

都市公園の遊具は、国土交通省の指針でおおむね3〜12歳の子どもを対象とし、日本公園施設業協会の規準は3〜6歳と6〜12歳に分けて寸法を決めている1。報告書は6〜12歳の体の目安を体重20〜41 kg、身長115〜149 cmとし、大人は子どもより体重が重く、着地の衝撃が大きいと書いている3。

遊園地の乗り物やウォータースライダーは、建築基準法の「遊戯施設」として別の決まりに従う。国土交通省が2017年に出した告示は、乗り物の座席に生じる加速度のうち0.2秒以上続くものを大きさで区分し、区分ごとにシートベルトなどの備えを定めた。止まっているときは、上下の加速度を9.8 m/s²(1 G)とする。横向きの加速度が3 m/s²(約0.3 G)以上なら横滑りを防ぐ形が、5 m/s²以上なら体を守る緩衝材が要り、12 m/s²以上は国の認定の対象になる56。

同じ告示はウォータースライダーについて、水路の両側に高さ50 cm以上の壁を求め、遠心力が掛かる曲がった所では、外へ飛び出さないための高さをさらに足すよう定めている。出口の壁を省けるのは、出口の手前1.5 m以上をまっすぐにし、深さ85 cm以上、長さ6 m以上のプールを置いた場合に限られる5。らせんの溝で体が外へ傾く角度が大きいほど、高い壁が要る。

省いていること

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

  • 人は1つの点として動かし、姿勢の変化、手や足でのブレーキ、足からの着地は入れていない。現実のけがは、前へ進む勢いを足で止めるときに多く起きているが、その横向きの衝撃はGの数字には表れない
  • 動摩擦係数は、速さと体重によらない一定の値にした。服・ぬれ・汚れ・面の材質では大きく変わり、ローラー式の滑り台では抵抗が速さと重さで変わることが測られている4
  • 着地のGは、体と面が合わせて10 cmつぶれる、ばねのような受け止め方を仮定した値である。つぶれる量が半分なら、Gの増え分は2倍になる。水に落ちたときは40 cm沈むとした
  • 空気の抵抗のは、70 kgの人が座った姿勢で0.35 m²と見積もり、体重の2/3乗に比例させた。らせんでは体がすぐに傾いて落ち着くとし、溝の中での揺れや、浮いている間に外へ飛び出す動きは計算していない
  • Gは刻みごとの値で、告示のように0.2秒以上続くものに限って数えてはいない。着地のGが続くのは0.1〜0.2秒である
参考文献

参考文献

  1. 北九州市「皿倉山滑り台あり方検討会議 基本資料集」(第1回 会議資料), 2025. https://www.city.kitakyushu.lg.jp/files/001168039.pdf
  2. 北九州市「第1回 皿倉山滑り台あり方検討会議 議事要旨」2025. https://www.city.kitakyushu.lg.jp/files/001169169.pdf
  3. 皿倉山滑り台あり方検討会議「報告書」(第2回 会議資料), 北九州市, 2026. https://www.city.kitakyushu.lg.jp/files/001188250.pdf
  4. 村田次郎, 塩田将基「すべり台の動摩擦係数の実測研究」『物理教育』71(2), 95–100, 日本物理教育学会, 2023. https://doi.org/10.20653/pesj.71.2_95
  5. 国土交通省「遊戯施設の客席部分の構造方法を定める件(平成29年国土交通省告示第247号)」2017. https://www.mlit.go.jp/common/001178586.pdf
  6. 国土交通省住宅局建築指導課長「遊戯施設の客席部分の構造方法を定める件等の改正について(技術的助言)」2017. https://www.mlit.go.jp/common/001178585.pdf

注

  1. 参考文献1。滑り台の長さ・材質・傾き・対象年齢、同じ製品の設置の数、響灘緑地のけが、看板の文言、国土交通省の指針と日本公園施設業協会の規準(JPFA-SP-S:2024)の対象年齢。 ↩ ↩2 ↩3 ↩4

  2. 参考文献2。 ↩ ↩2 ↩3

  3. 参考文献3。けがをした人への聞き取り、医師・運動の専門家・メーカー・協会の見解、結論。 ↩ ↩2 ↩3

  4. 参考文献4。ローラー式は直線7.5 m・傾き13°、ステンレスの板の滑り台は直線2.4 m・傾き32°で測っている。 ↩ ↩2

  5. 参考文献5。建築基準法施行令に基づく告示。加速度の区分は告示の第一、ウォータースライダーの壁と出口は第二にある。 ↩ ↩2

  6. 参考文献6(国住指第4511号)。加速度は0.2秒以上続くものを数えること、横向きの加速度の区分の説明。 ↩

プログラム
プログラム

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

このシミュレーションは、1/240秒ごとに「人がいる場所の傾きと曲がりを調べる → 面が押す力NとGを求める → 摩擦と空気の抵抗を引いて速さと位置を進める」をくり返している。Nが0を下回ったら面から離れて飛ばし、面か地面に戻ったら着地の衝撃を加える。場所ごとのG(滑り台の線の太さと網点)は、形や条件が変わるたびに、同じ計算を画面には出さずに最後まで走らせて求めている。

長さはm、速さはm/s、Gは体重との比

計算はすべてm・秒・kgで行い、画面に出すときだけkm/hに直す。空気の抵抗のは、70 kgの人が座った姿勢で0.35 m²という見積もりである。着地で体と面がつぶれる長さCUSHIONは10 cm、水に落ちたときは40 cmとした。

定数と単位(src/sims/long-slide.ts の 26〜39 行)
const G0 = 9.80665; // 重力加速度 g(m/s²)const RHO = 1.2; // 空気の密度 ρ(kg/m³)const CDA70 = 0.35; // 70 kg の人が座った姿勢の C_d·A(m²)const CUSHION = 0.1; // 着地で体と面(マット)が合わせてつぶれる量(m)const CUSHION_W = 0.4; // 水に落ちたときに止まるまでに沈む量(m)const MU_MAT = 0.6; // 出口の先のマットを滑るときの摩擦係数const H = 1 / 240; // 計算の刻み(秒)const G_MARK = 2; // 印を付ける G(このシミュレーションの目安。規格の値ではない)const CHILD = 20; // 並べて滑らせる子どもの体重(kg)const DX = 0.1; // 形を描く・G を記録する間隔(m)const RAMP = 2; // らせんの入口と出口で、曲がりを 0 から 1/R_h まで変える長さ(m)const KMH = 3.6; // 1 m/s = 3.6 km/hconst HN = 5000; // G の記録の数の上限const DEG = 180 / Math.PI;

G_MARKは滑り台に印を付けるGで、このシミュレーションの目安として2にした。法令や規格の値ではない。

取っ手の点を通るなめらかな曲線

滑り台の形は、四角の取っ手の点を通る自然3次スプラインで作る。点と点のあいだを3次式でつなぎ、つなぎ目で傾きだけでなく曲がり(2階微分)もそろえる。曲がりがとぎれないので、本文のの項が急に飛ばず、Gもなめらかに変わる。各点の2階微分は、3重対角の連立方程式を前から消して後ろから代入する方法(トーマス法)で解いている。

滑り台の形(src/sims/long-slide.ts の 51〜110 行)
/** 取っ手の点を通る自然 3 次スプライン。各点での y の 2 階微分 M を、3 重対角の連立方程式で求める */function solve(c: Course) {  const n = c.P.length, xs = new Float64Array(n), ys = new Float64Array(n), ms = new Float64Array(n);  c.P.forEach(([x, y], i) => { xs[i] = x; ys[i] = y; });  if (n >= 3) {    const cp = new Float64Array(n), dp = new Float64Array(n);    for (let i = 1; i < n - 1; i++) {      const h0 = xs[i] - xs[i - 1], h1 = xs[i + 1] - xs[i];      const d = 6 * ((ys[i + 1] - ys[i]) / h1 - (ys[i] - ys[i - 1]) / h0), den = 2 * (h0 + h1) - h0 * cp[i - 1];      cp[i] = h1 / den; dp[i] = (d - h0 * dp[i - 1]) / den;    }    for (let i = n - 2; i >= 1; i--) ms[i] = dp[i] - cp[i] * ms[i + 1];  }  c.xs = xs; c.ys = ys; c.ms = ms; c.x0 = xs[0]; c.x1 = xs[n - 1]; c.y0 = ys[0];}/** 水平の位置 x での高さ y と、傾き y′・曲がり y″ */const E = { y: 0, d1: 0, d2: 0 };function at(c: Course, x: number) {  const X = c.xs, Y = c.ys, M = c.ms, n = X.length;  x = clamp(x, X[0], X[n - 1]);  let lo = 0, hi = n - 2;  while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (X[mid] <= x) lo = mid; else hi = mid - 1; }  const i = lo, h = X[i + 1] - X[i], A = (X[i + 1] - x) / h, B = 1 - A;  E.y = A * Y[i] + B * Y[i + 1] + ((A * A * A - A) * M[i] + (B * B * B - B) * M[i + 1]) * h * h / 6;  E.d1 = (Y[i + 1] - Y[i]) / h - ((3 * A * A - 1) / 6) * h * M[i] + ((3 * B * B - 1) / 6) * h * M[i + 1];  E.d2 = A * M[i] + B * M[i + 1];  return E;}/** 上から見た曲がり 1/R_h(らせんの部分だけ。入口と出口で少しずつ変える) */function khOf(c: Course, x: number) {  const sp = c.spiral;  if (!sp || x <= sp.xa || x >= sp.xb) return 0;  return Math.min(1, (x - sp.xa) / RAMP, (sp.xb - x) / RAMP) / sp.R;}/** 形を作り直す: スプライン・描くための点(DX おき)・上から見た道すじ */function build(c: Course) {  solve(c);  const n = Math.max(2, Math.ceil((c.x1 - c.x0) / DX) + 1);  if (c.n !== n) { c.n = n; c.px = new Float32Array(n); c.py = new Float32Array(n); c.pa = new Float32Array(n); c.pk = new Float32Array(n); c.qx = new Float32Array(n); c.qy = new Float32Array(n); }  let psi = 0, qx = 0, qy = 0;  for (let i = 0; i < n; i++) {    const x = Math.min(c.x1, c.x0 + i * DX), e = at(c, x), q = 1 / Math.sqrt(1 + e.d1 * e.d1);    c.px[i] = x; c.py[i] = e.y; c.pa[i] = Math.atan(e.d1); c.pk[i] = e.d2 * q * q * q;    c.qx[i] = qx; c.qy[i] = qy;    psi += khOf(c, x) * DX; qx += Math.cos(psi) * DX; qy += Math.sin(psi) * DX;  }  // らせんの中心(上から見た図の真ん中に置く)  const sp = c.spiral;  if (sp) {    const xm = (sp.xa + sp.xb) / 2, i = Math.round((xm - c.x0) / DX);    let ps = 0; for (let k = 0; k < i; k++) ps += khOf(c, c.x0 + k * DX) * DX;    c.qc = [c.qx[i] - Math.sin(ps) * sp.R, c.qy[i] + Math.cos(ps) * sp.R];  } else c.qc = [0, 0];}function makeCourse(g: Geo, mu: number): Course {  const P: Pt[] = []; for (let i = 0; i + 1 < g.pts.length; i += 2) P.push([g.pts[i], g.pts[i + 1]]);  const c = { P, mu, end: g.end, hill: g.hill, spiral: g.spiral ? { ...g.spiral } : null, run: g.run, extra: g.extra, n: 0 } as unknown as Course;  build(c);  return c;}

atは水平の位置での高さ、傾き、2階微分を返す。傾きの角度は、曲がりはで、半径はである。らせんの場面では、横から見た形とは別に、上から見た曲がりをkhOfが返す。入口と出口では、横向きの力が急に掛からないように、曲がりを2 mかけて0から変えている。

面が押す力とG

contactが本文の2つ目の式をそのまま計算する。avは面に垂直な上向きの加速度(谷でが正、山で負)、alはらせんの横向きの加速度である。は、坂に沿って下りながら回るので、上から見た速さがになる分を入れている。2つを合わせた大きさをで割ったものがGで、向きの比から溝の中の傾きが決まる。摩擦は合わせた大きさにを掛けるので、らせんでは横向きの力の分だけ摩擦も増える。

力とG(src/sims/long-slide.ts の 136〜154 行)
/** 面の上にいるときの力。面が押す加速度(上向き N_v/m と横向き N_l/m)から G と溝の中の傾き φ を決める。 *  ai は着地の衝撃で面が余分に押す加速度。戻り値が負なら、面から浮く */function contact(c: Course, r: Rider, ai: number) {  const e = at(c, r.x), q = 1 / Math.sqrt(1 + e.d1 * e.d1), cs = q, sn = e.d1 * q, kap = e.d2 * q * q * q, v = r.v;  const av = G0 * cs + v * v * kap + ai; // N_v / m = g cosθ + v²κ(+ 衝撃)  const al = v * v * khOf(c, r.x) * cs * cs; // N_l / m = v² cos²θ / R_h  r.ang = Math.atan2(sn, cs); r.kap = kap;  if (av < 0) return av;  const an = Math.hypot(av, al);  r.G = an / G0; r.gv = av / G0; r.gl = al / G0; r.phi = Math.atan2(al, av);  r.fr = c.mu * an; r.dr = r.k * v * v;  return av;}/** 着地: 面に垂直に当たる速さ vn を、つぶれる量 d で止める。ばねで止めたときの力は半分の正弦波の形になり、 *  いちばん大きい加速度は vn²/d、続く時間は πd/vn */function impact(r: Rider, vn: number, d: number) {  if (vn < 0.3) return;  r.impT = (Math.PI * d) / vn; r.impP = (vn * vn) / d; r.imp = 0; r.eI += 0.5 * vn * vn;}

impactは着地の衝撃である。面に垂直な速さを長さで止めるばねとみなし、加速度を高さ・長さ秒の半分の正弦波として、刻みごとにavに足す。面に垂直な速さは、そこで失われる(はね返らない)。

1刻みずつ進め、浮いたら放物線で飛ばす

stepは人の状態ごとに分かれている。面の上では、本文の運動の式で速さを進め、その速さでをだけ進める。摩擦は動く向きと逆に掛けるが、摩擦だけで向きが反転することはないので、速さが0をまたいだら0で止める。止まったときは、重さの分力が摩擦を超えるかどうかで、また動き出すかを決める。

時間を進める(src/sims/long-slide.ts の 156〜250 行)
function launch(r: Rider, cs: number, sn: number) {  r.mode = 2; r.vx = r.v * cs; r.vy = r.v * sn;  const n = r.airs.length;  if (n >= 2 && Math.abs(r.x - r.airs[n - 1]) < 0.3) r.airs[n - 1] = NaN; // 着地してすぐまた浮いたときは、1 回の浮き上がりとしてつなげる  else { r.airs.push(r.x, NaN); if (r.an < r.ax.length - 1) { r.ax[r.an] = NaN; r.ay[r.an] = NaN; r.an++; } }}function finish(c: Course, r: Rider, ev: Ev | null) {  r.mode = 4; r.v = 0;  r.why = r.x > c.x1 ? (c.end === "pool" ? 4 : 3) : r.x >= c.x1 - c.run ? 1 : 2;  ev?.("stop", r, r.why);}function record(r: Rider) {  if (r.G > r.maxG) { r.maxG = r.G; r.maxX = r.x; r.maxLand = r.impT > 0; }  const n = r.hn;  if (n < HN && (n === 0 || Math.abs(r.x - r.hx[n - 1]) > 0.04 || Math.abs(r.G - r.hg[n - 1]) > 0.04)) { r.hx[n] = r.x; r.hg[n] = r.G; r.hn++; }}/** h 秒進める(半陰的オイラー法: 今の力で速さを進め、新しい速さで位置を進める) */function step(c: Course, r: Rider, h: number, ev: Ev | null) {  if (r.mode === 0 || r.mode === 4) return;  r.t += h;  let ai = 0;  if (r.impT > 0) { ai = r.impP * Math.sin(Math.PI * Math.min(1, r.imp / r.impT)); r.imp += h; if (r.imp >= r.impT) r.impT = 0; }  if (r.mode === 1) {    const av = contact(c, r, ai), cs = Math.cos(r.ang), sn = Math.sin(r.ang), v = r.v;    if (av < 0) { launch(r, cs, sn); r.G = 0; r.gv = 0; r.gl = 0; ev?.("lift", r, r.x); record(r); return; }    if (r.phi > 75 / DEG && r.maxPhi <= 75 / DEG) ev?.("wall", r, r.phi);    r.maxPhi = Math.max(r.maxPhi, r.phi);    const gt = -G0 * sn; // 重力の、面に沿った分    let nv: number;    if (v === 0) nv = Math.abs(gt) > r.fr ? (gt - Math.sign(gt) * r.fr) * h : 0; // 止まっているときは静止摩擦で支えられるか    else { nv = v + (gt - Math.sign(v) * (r.fr + r.dr)) * h; if (nv * v < 0) nv = 0; } // 摩擦で向きは反転しない    r.eF += r.fr * Math.abs(v) * h; r.eA += r.dr * Math.abs(v) * h;    r.v = nv; r.x += nv * cs * h; r.dist += Math.abs(nv) * h;    if (r.x >= c.x1) { // 出口から飛び出す      r.x = c.x1; const e = at(c, c.x1), q = 1 / Math.sqrt(1 + e.d1 * e.d1); r.y = e.y;      r.exitV = nv; r.exitT = r.t; launch(r, q, e.d1 * q); ev?.("exit", r, nv); record(r); return;    }    if (r.x <= c.x0) { r.x = c.x0; r.v = 0; }    r.y = at(c, r.x).y;    if (nv === 0 && Math.abs(gt) <= r.fr + 1e-9) { r.still += h; if (r.still > 0.3) finish(c, r, ev); } else r.still = 0;  } else if (r.mode === 2) {    const sp = Math.hypot(r.vx, r.vy), kd = r.k * sp; // 空気の抵抗の加速度 = k v²(速さと逆向き)    r.vx -= kd * r.vx * h; r.vy -= (G0 + kd * r.vy) * h;    r.x += r.vx * h; r.y += r.vy * h; r.air += h; r.dist += sp * h; r.eA += kd * sp * sp * h;    r.G = (kd * sp) / G0; r.gv = r.G; r.gl = 0; r.fr = 0; r.dr = kd * sp; r.ang = Math.atan2(r.vy, r.vx);    if (r.an < r.ax.length && (r.an === 0 || Math.hypot(r.x - r.ax[r.an - 1], r.y - r.ay[r.an - 1]) > 0.08 || Number.isNaN(r.ax[r.an - 1]))) { r.ax[r.an] = r.x; r.ay[r.an] = r.y; r.an++; }    if (r.x > c.x0 && r.x < c.x1) {      const e = at(c, r.x);      if (r.y <= e.y) { // 面に着地        const q = 1 / Math.sqrt(1 + e.d1 * e.d1), cs = q, sn = e.d1 * q;        const vt = r.vx * cs + r.vy * sn, vn = r.vx * sn - r.vy * cs;        r.mode = 1; r.y = e.y; r.v = vt; r.airs[r.airs.length - 1] = r.x;        impact(r, vn, CUSHION); ev?.("land", r, vn);      }    } else if (r.x >= c.x1 && r.y <= 0) { // 出口の先の地面か水に落ちる      r.mode = 3; r.y = 0; r.v = Math.max(0, r.vx); r.landX = r.x; r.airs[r.airs.length - 1] = r.x;      impact(r, -r.vy, c.end === "pool" ? CUSHION_W : CUSHION); ev?.("ground", r, -r.vy);    } else if (r.x <= c.x0 && r.y <= c.y0) { r.mode = 1; r.x = c.x0; r.v = 0; r.y = c.y0; r.airs[r.airs.length - 1] = r.x; }  } else if (r.mode === 3) {    const an = G0 + ai, v = r.v;    r.G = an / G0; r.gv = r.G; r.gl = 0; r.ang = 0; r.dr = r.k * v * v;    if (c.end === "pool") { r.fr = v / 0.5; r.v = v * Math.exp(-h / 0.5); } // 水の抵抗: 0.5 秒ごとに速さが 1/e に    else { r.fr = MU_MAT * an; r.v = Math.max(0, v - (r.fr + r.dr) * h); }    r.eF += r.fr * v * h; r.x += r.v * h; r.dist += r.v * h;    if (r.v < (c.end === "pool" ? 0.15 : 1e-3) && r.impT === 0) finish(c, r, ev);  }  if (r.t > 120 && r.mode !== 4) finish(c, r, ev);  record(r);} /** 先回りの計算: 今の形と条件で 1 人を最後まで滑らせ、場所ごとの G(描く太さと網点)と、印の位置を求める */interface Pred { bins: Float32Array; gx: Float32Array; gg: Float32Array; gn: number; maxG: number; t: number; why: number; exitV: number; landX: number; dist: number; air: number; marks: [number, number, boolean][]; lifts: number[]; tMax: number }function predict(c: Course, p: Pred, r: Rider, m: number, air: boolean, v0: number) {  place(c, r, c.x0, m, air, v0);  p.bins.fill(-1);  const lands: number[] = [];  let tMax = 0, gMax = 0;  const ev: Ev = (k, rr) => { if (k === "land" || k === "ground") lands.push(rr.x); };  for (let i = 0; i < 120 / H && r.mode !== 4; i++) {    step(c, r, H, ev);    if (r.mode === 1) { const j = Math.round((r.x - c.x0) / DX); if (j >= 0 && j < c.n && r.G > p.bins[j]) p.bins[j] = r.G; }    else if (r.mode === 2 && r.x < c.x1) { const j = Math.round((r.x - c.x0) / DX); if (j >= 0 && j < c.n && p.bins[j] < 0) p.bins[j] = 0; }    if (r.G > gMax) { gMax = r.G; tMax = r.t; }  }  p.gn = r.hn; p.gx.set(r.hx.subarray(0, r.hn)); p.gg.set(r.hg.subarray(0, r.hn));  p.maxG = r.maxG; p.t = r.t; p.why = r.why; p.exitV = r.exitV; p.landX = r.landX; p.dist = r.dist; p.air = r.air; p.lifts = r.airs.slice(); p.tMax = tMax;  // 印: G が G_MARK を超えた区間ごとに、いちばん大きい所  p.marks = [];  let best = -1, bx = 0;  for (let i = 0; i <= p.gn; i++) {    const g = i < p.gn ? p.gg[i] : 0;    if (g >= G_MARK) { if (g > best) { best = g; bx = p.gx[i]; } }    else if (best > 0) { p.marks.push([bx, best, lands.some((x) => Math.abs(x - bx) < 1.2)]); best = -1; }  }}

avが負になったらlaunchで面を離れ、重力と空気の抵抗だけを受けて飛ぶ。飛んでいる間のGは、空気の抵抗の加速度をで割った小さな値になる。高さが面の下に入った刻みで着地とし、速さを面に沿った分と垂直な分に分けて、で滑り続ける。出口を越えたら地面(マットかプール)まで飛び、マットの上は摩擦係数0.6、水の中は0.5秒ごとに速さがになる抵抗で止める。

計算は、今の力で速さを進めてから位置を進める半陰的オイラー法で、刻みは1/240秒と細かくしている。半径2.8 mの谷を秒速9 mで通るとき、1刻みに進むのは4 cmほどで、曲がりの変化を十分に追える。刻みは画面のコマ数によらず一定なので、どの端末でも同じ滑りになる。

predictは、同じstepを画面に出さずに最後まで走らせ、0.1 mごとの区画に、そこで受けたGのいちばん大きい値を記録する。描く太さと網点、グラフの点線、Gが2を超えた所の印は、この結果から作っている。

取っ手を動かす・点を足す・滑らせる

指を置いた所に取っ手があれば、その点を動かす。両端の点は高さだけ、間の点は隣の点との間に0.8 mを残す範囲で動く。点を動かすたびにスプラインを作り直し、先回りの計算もやり直す。「+」の印をたたくと、その区間の真ん中に、形を変えずに点を足す。滑り台の線の近くをたたくと、そこから滑らせる。

操作(src/sims/long-slide.ts の 304〜359 行)
function layout(api: Api, s: S): Lay {  const w = api.w, h = api.h, u = api.u, pad = u(12), iw = w - 2 * pad;  const [wx0, wx1, wy0, wy1] = s.box, ww = wx1 - wx0, wh = wy1 - wy0;  const ovH = clamp((iw * wh) / ww + u(10), h * 0.22, h * 0.42), k = Math.min(iw / ww, (ovH - u(6)) / wh);  const ox = pad + (iw - k * ww) / 2, oy = pad + ovH;  const grH = clamp(h * 0.18, u(84), u(150)), gr = { x: pad, y: pad + ovH + u(6), w: iw, h: grH };  // 画面全体の形では、右下の角に「記事ビュー」のボタン(下から 50 px)が重なるので、右下の箱はその分だけ短くする  const top = gr.y + grH + u(10), rest = Math.max(u(90), h - top - pad), side = iw / rest > 1.3 && iw > u(520), btn = api.full ? Math.max(0, 52 - pad) : 0;  // 縦に積むとき、エネルギーの帯は低くてよい。らせんの図(上から見た形と断面)には高さが要る  const pnH = side ? rest - btn : s.c.spiral ? Math.max(rest * 0.42, u(150)) : Math.min(rest * 0.4, u(118) + btn);  const cl = side ? { x: pad, y: top, w: iw * 0.6 - u(5), h: rest } : { x: pad, y: top, w: iw, h: rest - pnH - u(8) };  const pn = side ? { x: pad + iw * 0.6 + u(5), y: top, w: iw * 0.4 - u(5), h: pnH } : { x: pad, y: top + rest - pnH, w: iw, h: pnH - btn };  return { ov: { x: pad, y: pad, w: iw, h: ovH }, gr, cl, pn, k, X: (x) => ox + (x - wx0) * k, Y: (y) => oy - (y - wy0) * k, ix: (px) => wx0 + (px - ox) / k, iy: (py) => wy0 + (oy - py) / k, plan: null };} function pointer(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S) {  const L = s.lay, P = api.pointer, c = s.c;  if (!L) return;  const u = api.u, hit = Math.max(u(15), 18);  if (type === "down") {    s.grab = null;    if (L.plan && c.spiral && Math.hypot(P.x - L.plan.hx, P.y - L.plan.hy) < hit) { s.grab = { kind: "R", i: 0, x: 0, moved: false, pk: L.plan.k }; return; }    if (P.y > L.ov.y + L.ov.h + u(4)) return;    let bi = -1, bd = hit;    c.P.forEach(([x, y], i) => { const d = Math.hypot(L.X(x) - P.x, L.Y(y) - P.y); if (d < bd) { bd = d; bi = i; } });    if (bi >= 0) { s.grab = { kind: "pt", i: bi, x: 0, moved: false, pk: 0 }; return; }    for (let i = 0; i + 1 < c.P.length; i++) {      if (L.X(c.P[i + 1][0]) - L.X(c.P[i][0]) < Math.max(u(9), 11) * 2.6) continue;      const xm = (c.P[i][0] + c.P[i + 1][0]) / 2, ym = at(c, xm).y;      if (Math.hypot(L.X(xm) - P.x, L.Y(ym) + u(16) - P.y) < Math.max(u(11), 14)) { s.grab = { kind: "add", i, x: xm, moved: false, pk: 0 }; return; }    }    const wx = L.ix(P.x);    if (wx >= c.x0 - 1.4 && wx <= c.x1) { const ys = at(c, Math.max(wx, c.x0)).y; if (Math.abs(L.Y(ys) - P.y) < Math.max(u(18), 20)) s.grab = { kind: "place", i: 0, x: Math.max(wx, c.x0), moved: false, pk: 0 }; }  } else if (type === "move" && s.grab && P.down) {    const g = s.grab;    if (Math.hypot(P.x - P.x0, P.y - P.y0) > 6) g.moved = true;    if (g.kind === "pt" && g.moved) {      const n = c.P.length, i = g.i, top = s.box[3] - 1.4;      const x = i === 0 || i === n - 1 ? c.P[i][0] : clamp(L.ix(P.x), c.P[i - 1][0] + 0.8, c.P[i + 1][0] - 0.8);      c.P[i] = [x, clamp(L.iy(P.y), i === n - 1 ? 0.2 : 0.3, top)];      build(c); s.dirty = true; hold(s); s.wait = 1;    } else if (g.kind === "R" && g.moved && L.plan && c.spiral) {      c.spiral.R = clamp((P.x - L.plan.cx) / g.pk, 1.2, 6); s.planK = g.pk;      build(c); s.dirty = true; hold(s); s.wait = 1;    }  } else if (type === "up" || type === "cancel") {    const g = s.grab; s.grab = null;    if (!g || type === "cancel") return;    if (g.kind === "add" && !g.moved) {      if (c.P.length >= 16) { api.note("点はこれ以上足せません(16 個まで)"); return; }      c.P.splice(g.i + 1, 0, [g.x, at(c, g.x).y]); build(c); s.dirty = true;      api.note("点を足した。四角の取っ手をドラッグすると形が変わる", 2000);    } else if (g.kind === "place" && !g.moved) start(api, s, g.x);    else if (g.kind === "pt" || g.kind === "R") { s.planK = 0; if (g.moved) s.wait = 0.5; }  }}

描く

上の段の全体図とグラフは、水平の位置の目盛りをそろえてあるので、Gが大きい所が滑り台のどこかを縦に見比べられる。滑り台の線はGの0.5ごとに太さを変え、面の下の網点は4×4の決まった順(ディザ)に打つので、Gが大きい所ほど濃く見える。

描画(src/sims/long-slide.ts の 361〜709 行)
/** 座った人の絵。(X, Y) は面に触れている点、a は進む向き(水平から上向きが正)、k は 1 m の画素数 */function person(api: Api, X: number, Y: number, a: number, k: number, small: boolean, up: boolean) {  const sc = (small ? 0.68 : 1) * k, tx = Math.cos(a), ty = -Math.sin(a), nx = -Math.sin(a), ny = -Math.cos(a);  const P = (p: number, q: number): Pt => [X + (tx * p + nx * q) * sc, Y + (ty * p + ny * q) * sc];  const hip = P(0, 0.13), knee = P(0.45, 0.26), foot = P(0.93, 0.13), sh = P(-0.14, 0.64), hd = P(-0.18, 0.83), hand = up ? P(-0.05, 1.05) : P(0.34, 0.33);  const lw = Math.max(api.u(1.1), 0.06 * sc);  api.poly([foot, knee, hip, sh], { w: lw });  api.line(sh[0], sh[1], hand[0], hand[1], { w: lw * 0.8 });  api.circle(hd[0], hd[1], 0.12 * sc, { fill: "paper", w: lw * 0.8 });}/** 枠からはみ出さないように、地の色の札の上に文字を書く */function tag(api: Api, t: string, x: number, y: number, fs: number, al: CanvasTextAlign = "left", b?: Box) {  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, lo = (b?.x ?? 0) + api.u(3), hi = (b ? b.x + b.w : api.w) - api.u(3);  api.label(t, x + Math.max(lo - x0, 0) + Math.min(hi - (x0 + wd), 0), y, { size: fs, font: "mono", align: al });}const gOf = (s: S, i: number) => s.pred.bins[clamp(i, 0, s.c.n - 1)];/** 滑り台の面の線を、場所ごとの G で太さを変えて描く(G の 0.5 ごとにまとめて 1 本の線にする) */function surface(api: Api, s: S, pts: Pt[], idx: number[], base: number, per: number) {  const ctx = api.ctx;  let lvl = -2;  ctx.strokeStyle = api.ink; ctx.lineCap = "round"; ctx.setLineDash([]);  for (let i = 0; i + 1 < pts.length; i++) {    const g = gOf(s, idx[i]), L = g < 0 ? -1 : Math.round(clamp(g, 0, 5) * 2);    if (L !== lvl) { if (lvl > -2) ctx.stroke(); lvl = L; ctx.beginPath(); ctx.moveTo(pts[i][0], pts[i][1]); ctx.lineWidth = L < 0 ? base * 0.6 : base + per * (L / 2); }    ctx.lineTo(pts[i + 1][0], pts[i + 1][1]);  }  if (lvl > -2) ctx.stroke();  ctx.lineCap = "butt";}/** 面の下の網点の帯: G が大きい所ほど濃い(4×4 の決まった順に点を打つ) */function band(api: Api, s: S, pts: Pt[], idx: number[], gap: number, rows: number) {  const ctx = api.ctx, r = gap * 0.32;  ctx.beginPath();  let acc = 0, j = 0;  for (let i = 0; i + 1 < pts.length; i++) {    const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], d = Math.hypot(x1 - x0, y1 - y0);    if (d < 1e-6) continue;    const dx = (x1 - x0) / d, dy = (y1 - y0) / d, g = Math.max(0, gOf(s, idx[i])) / 3;    while (acc <= d) {      const px = x0 + dx * acc, py = y0 + dy * acc;      for (let k = 0; k < rows; k++) {        if (g <= BAY[(j & 3) + (k & 3) * 4]) continue;        const dd = (k + 0.8) * gap, qx = px - dy * dd, qy = py + dx * dd;        ctx.moveTo(qx + r, qy); ctx.arc(qx, qy, r, 0, TAU);      }      acc += gap; j++;    }    acc -= d;  }  ctx.fillStyle = api.ink; ctx.fill();} function drawOverview(api: Api, s: S, L: Lay) {  const c = s.c, u = api.u, X = L.X, Y = L.Y, k = L.k, [wx0, wx1, wy0] = s.box, gy = Y(0), fs = u(10.5);  // 雲(右上の空いた所)  for (let i = 0; i < 3; i++) {    const cx = L.ov.x + L.ov.w * (0.62 + 0.13 * i), cy = L.ov.y + u(16) + u(13) * (i % 2), wx = L.ix(cx);    if (wx < c.x1 && Y(at(c, wx).y) - cy < u(40)) continue;    const q: Pt[] = []; for (let a = 0; a <= 24; a++) { const t = (a / 24) * TAU; q.push([cx + Math.cos(t) * u(26), cy + Math.sin(t) * u(6) * (Math.sin(t) < 0 ? 1.5 : 0.7)]); }    api.poly(q, { close: true, w: u(0.8), dash: [u(1.2), u(3)] });  }  // 地面・丘・プール・マット  const pts: Pt[] = [], idx: number[] = [];  for (let i = 0; i < c.n; i += 2) { pts.push([X(c.px[i]), Y(c.py[i])]); idx.push(i); }  pts.push([X(c.x1), Y(c.py[c.n - 1])]); idx.push(c.n - 1);  if (c.hill) {    const hp: Pt[] = [[X(wx0), Y(c.y0 - 0.25)]];    for (const [x, y] of pts) hp.push([x, y + Math.max(u(3), 0.25 * k)]);    hp.push([X(c.x1 + 0.8), gy], [X(wx1), gy], [X(wx1), Y(wy0)], [X(wx0), Y(wy0)]);    api.hatch(hp, { gap: u(6.5), angle: 45, w: u(0.55) });    api.poly(hp.slice(0, hp.length - 3), { w: u(1) });  } else api.hatch([[X(wx0), gy], [X(wx1), gy], [X(wx1), Y(wy0)], [X(wx0), Y(wy0)]], { gap: u(6.5), angle: 45, w: u(0.55) });  api.line(X(wx0), gy, X(wx1), gy, { w: u(1.2) });  if (c.end === "pool") {    const px0 = X(c.x1 - 0.2), py1 = Y(-0.85);    api.rect(px0, gy, X(wx1) - px0, py1 - gy, { fill: "paper", stroke: false });    for (let yy = gy + u(4); yy < py1; yy += u(4)) api.line(px0 + u(2), yy, X(wx1), yy, { w: u(0.5), dash: [u(5), u(3)] });    api.line(px0, gy, px0, py1, { w: u(1.2) }); api.line(px0, py1, X(wx1), py1, { w: u(1.2) });    tag(api, "プール", X(c.x1 + 2), py1 - u(3), fs, "left", L.ov);  } else {    const m0 = X(c.x1 + 0.15), m1 = X(c.x1 + 3.2), mh = Math.max(u(3), 0.08 * k);    api.rect(m0, gy - mh, m1 - m0, mh, { fill: "ink", stroke: false });    tag(api, "マット", (m0 + m1) / 2, gy + u(13), fs, "center", L.ov);  }  // 柱・スタートの台・はしご  const deck = X(c.x0 - 1.3), dY = Y(c.y0);  if (!c.hill) {    for (let x = c.x0 + 2; x < c.x1 - 1; x += 3) { const y = at(c, x).y; api.line(X(x), Y(y) + u(2), X(x), gy, { w: u(0.9) }); }    api.line(deck, dY, deck, gy, { w: u(1.1) }); api.line(X(c.x0), dY, X(c.x0), gy, { w: u(1.1) });    const lx0 = X(c.x0 - 1.3), lx1 = X(c.x0 - 2.9), lx2 = X(c.x0 - 0.9), lx3 = X(c.x0 - 2.5);    api.line(lx0, dY, lx1, gy, { w: u(0.9) }); api.line(lx2, dY, lx3, gy, { w: u(0.9) });    const nr = Math.max(3, Math.round(c.y0 / 0.35));    for (let i = 1; i < nr; i++) { const t = i / nr, ya = dY + (gy - dY) * t; api.line(lx0 + (lx1 - lx0) * t, ya, lx2 + (lx3 - lx2) * t, ya, { w: u(0.6) }); }  }  api.rect(deck, dY, X(c.x0) - deck, Math.max(u(2.5), 0.15 * k), { fill: "ink", stroke: false });  api.line(deck, dY, deck, dY - 1.0 * k, { w: u(0.8) }); api.line(deck, dY - 1.0 * k, X(c.x0 - 0.1), dY - 1.0 * k, { w: u(0.8) });  tag(api, "スタート", deck, dY - 1.0 * k - u(6), fs, "left", L.ov);  // 溝の側面の上の縁・網点の帯・面の線  const rail: Pt[] = pts.map(([x, y], i) => { const a = c.pa[idx[i]], o = Math.max(u(3.5), 0.45 * k); return [x - Math.sin(a) * o, y - Math.cos(a) * o]; });  if (c.hill) { const under: Pt[] = [...pts, ...pts.slice().reverse().map(([x, y]): Pt => [x, y + u(14)])]; api.poly(under, { close: true, fill: "paper", stroke: false }); }  band(api, s, pts, idx, u(3.2), 4);  api.poly(rail, { w: u(0.7) });  for (let i = 0; i < pts.length; i += 6) api.line(pts[i][0], pts[i][1], rail[i][0], rail[i][1], { w: u(0.5) });  surface(api, s, pts, idx, u(1.2), u(1.6));  tag(api, "出口", X(c.x1) - u(8), Y(c.py[c.n - 1]) - u(14), fs, "right", L.ov);  // らせんの部分  const sp = c.spiral;  if (sp) {    const a0 = X(sp.xa), a1 = X(sp.xb), ya = Y(at(c, sp.xa).y) - u(10), yb = Y(at(c, sp.xb).y) - u(10);    let turn = 0; for (let x = sp.xa; x < sp.xb; x += DX) turn += khOf(c, x) * DX;    api.line(a0, ya, a1, yb, { w: u(0.8), dash: [u(4), u(3)] });    api.line(a0, ya - u(4), a0, ya + u(4), { w: u(0.8) }); api.line(a1, yb - u(4), a1, yb + u(4), { w: u(0.8) });    tag(api, `らせん ${fmt(turn / TAU, 1)} 回り(伸ばして描いた形)`, (a0 + a1) / 2 + u(10), (ya + yb) / 2 - u(10), fs, "left", L.ov);  }  // 浮く所と、G の大きい所の印  const lf = s.pred.lifts;  for (let i = 0; i + 1 < lf.length; i += 2) {    const xa = lf[i], xb = Number.isNaN(lf[i + 1]) ? c.x1 : Math.min(lf[i + 1], c.x1);    if (xb - xa < 0.2) continue;    const xm = (xa + xb) / 2; tag(api, "浮く", X(xm), Y(at(c, xm).y) + u(28), fs, "center", L.ov);  }  let lastX = -1e9, lift = 0; // 近い印の札は、上へずらして重ねない  for (const [x, g, land] of s.pred.marks) {    const xx = X(x), yy = x <= c.x1 ? Y(at(c, x).y) - Math.max(u(5), 0.45 * k) : gy - u(2);    lift = xx - lastX < u(70) ? lift + u(14) : 0; lastX = xx;    api.poly([[xx, yy - u(2)], [xx - u(5), yy - u(11)], [xx + u(5), yy - u(11)]], { close: true, fill: "ink", stroke: false });    tag(api, `${land ? "着地 " : ""}${fmt(g, 1)} G`, xx, yy - u(15) - lift, fs, "center", L.ov);  }  // 取っ手と、点を足す印  const hs = Math.max(u(9), 11);  for (let i = 0; i + 1 < c.P.length; i++) {    if (X(c.P[i + 1][0]) - X(c.P[i][0]) < hs * 2.6) continue; // 取っ手が近すぎる区間には出さない    const xm = (c.P[i][0] + c.P[i + 1][0]) / 2, mx = X(xm), my = Y(at(c, xm).y) + u(16), rr = Math.max(u(5), 6);    api.circle(mx, my, rr, { fill: "paper", w: u(0.8), dash: [u(1.5), u(1.5)] });    api.line(mx - rr * 0.55, my, mx + rr * 0.55, my, { w: u(0.9) }); api.line(mx, my - rr * 0.55, mx, my + rr * 0.55, { w: u(0.9) });  }  c.P.forEach(([x, y], i) => { const on = s.grab?.kind === "pt" && s.grab.i === i; api.rect(X(x) - hs / 2, Y(y) - hs / 2, hs, hs, { fill: on ? "ink" : "paper", w: u(1.3) }); });  // 飛んだ道すじと、滑る人  for (const rr of [s.kid, s.r]) {    if (rr.an < 2) continue;    const q: number[] = [];    for (let i = 0; i <= rr.an; i++) {      if (i === rr.an || Number.isNaN(rr.ax[i])) { if (q.length > 2) api.poly(q, { w: u(0.9), dash: [u(1.5), u(3)] }); q.length = 0; }      else q.push(X(rr.ax[i]), Y(rr.ay[i]));    }  }  const kf = Math.max(k, u(13));  for (const rr of [s.kid, s.r]) {    if (rr === s.kid && s.kid.mode === 0) continue;    const x = rr.mode === 0 ? c.x0 : rr.x, y = rr.mode === 0 ? c.y0 : rr.y, a = rr.mode === 0 ? c.pa[0] : rr.mode >= 3 ? 0 : rr.ang;    person(api, X(x), Y(y), a, kf, rr === s.kid, rr.mode === 2);  }} function drawGraph(api: Api, s: S, L: Lay) {  const b = L.gr, u = api.u, c = s.c, ctx = api.ctx, r = s.r, fs = u(10.5);  const x0 = L.X(s.box[0]), x1 = L.X(s.box[1]), top = b.y + u(20), bot = b.y + b.h - u(16);  const gTop = clamp(Math.ceil(Math.max(s.pred.maxG, r.maxG, s.kid.mode ? s.kid.maxG : 0, 2) + 0.15), 2, 8), stp = gTop > 5 ? 2 : 1;  const GY = (g: number) => bot - (clamp(g, 0, gTop) / gTop) * (bot - top);  for (let g = 0; g <= gTop; g += stp) api.line(x0, GY(g), x1, GY(g), { w: u(g === 0 ? 1 : 0.5), dash: g === 0 ? undefined : [u(1.5), u(4)] });  api.line(x0, GY(1), x1, GY(1), { w: u(1), dash: [u(6), u(4)] });  // 水平の位置の目盛り(スタートからの距離)  const span = c.x1 + c.extra - c.x0, tk = span > 45 ? 10 : 5;  for (let d = 0; d <= span; d += tk) { const xx = L.X(c.x0 + d); api.line(xx, bot, xx, bot + u(4), { w: u(0.8) }); api.text(d === 0 ? "0" : `${d} m`, xx, bot + u(13), { size: fs, font: "mono", align: "center" }); }  // 浮いた区間  for (let i = 0; i + 1 < r.airs.length; i += 2) {    const xa = L.X(r.airs[i]), xb = L.X(Number.isNaN(r.airs[i + 1]) ? r.x : r.airs[i + 1]);    if (xb - xa > 1) api.hatch([[xa, bot - u(7)], [xb, bot - u(7)], [xb, bot], [xa, bot]], { gap: u(2.5), angle: 45, w: u(0.6) });  }  const line = (gx: ArrayLike<number>, gg: ArrayLike<number>, n: number, w: number, dash: number[]) => {    if (n < 2) return;    ctx.beginPath(); for (let i = 0; i < n; i++) { const X = L.X(gx[i]), Y = GY(gg[i]); if (i) ctx.lineTo(X, Y); else ctx.moveTo(X, Y); }    ctx.lineWidth = w; ctx.strokeStyle = api.ink; ctx.setLineDash(dash); ctx.stroke(); ctx.setLineDash([]);  };  line(s.pred.gx, s.pred.gg, s.pred.gn, u(1), [u(1.2), u(2.6)]);  if (s.kid.mode) line(s.kid.hx, s.kid.hg, s.kid.hn, u(1.2), [u(5), u(3)]);  line(r.hx, r.hg, r.hn, u(2), []);  for (const [x, g] of s.pred.marks) { const xx = L.X(x), yy = GY(g); api.poly([[xx, yy - u(3)], [xx - u(4), yy - u(10)], [xx + u(4), yy - u(10)]], { close: true, fill: "ink", stroke: false }); if (g > gTop) tag(api, `${fmt(g, 1)} G`, xx + u(6), top + u(4), fs, "left", b); }  if (r.mode) { const xx = L.X(r.x); api.line(xx, top - u(4), xx, bot, { w: u(0.8), dash: [u(3), u(2)] }); api.circle(xx, GY(r.G), u(3.2), { fill: "ink", stroke: false }); }  for (let g = 0; g <= gTop; g += stp) tag(api, `${g} G`, x0 + u(2), GY(g) + fs * 0.35, fs, "left", b);  tag(api, "体に掛かる G(点線は先回りの計算、太線は今の滑り)", x0 + u(2), b.y + u(10), fs, "left", b);} function drawClose(api: Api, s: S, L: Lay) {  const b = L.cl, u = api.u, c = s.c, r = s.r, ctx = api.ctx, fs = u(10.5);  const kc = Math.min(b.h / 4.4, b.w / 6.4), cx = b.x + b.w * 0.42, cy = b.y + b.h * 0.62;  const [mx, my] = s.cam ?? [r.mode ? r.x : c.x0, r.mode ? r.y : c.y0];  const SX = (x: number) => cx + (x - mx) * kc, SY = (y: number) => cy - (y - my) * kc;  ctx.save(); ctx.beginPath(); ctx.rect(b.x, b.y, b.w, b.h); ctx.clip();  const xa = mx - (cx - b.x) / kc - 1, xb = mx + (b.x + b.w - cx) / kc + 1, gy = SY(0), bot = b.y + b.h;  // 地面・プール・マット  if (gy < bot) {    if (c.end === "pool" && xb > c.x1) {      const p0 = SX(c.x1 - 0.2);      if (p0 > b.x) api.hatch([[b.x, gy], [p0, gy], [p0, bot], [b.x, bot]], { gap: u(8), w: u(0.6) });      for (let yy = gy + u(5); yy < Math.min(bot, SY(-0.85)); yy += u(5)) api.line(Math.max(b.x, p0), yy, b.x + b.w, yy, { w: u(0.6), dash: [u(6), u(4)] });      api.line(p0, gy, p0, SY(-0.85), { w: u(1.4) });    } else api.hatch([[b.x, gy], [b.x + b.w, gy], [b.x + b.w, bot], [b.x, bot]], { gap: u(8), w: u(0.6) });    api.line(b.x, gy, b.x + b.w, gy, { w: u(1.4) });    if (c.end === "mat") api.rect(SX(c.x1 + 0.15), gy - 0.08 * kc, 3.05 * kc, 0.08 * kc, { fill: "ink", stroke: false });  }  const sa = Math.max(c.x0, xa), sb = Math.min(c.x1, xb);  if (sb > sa) {    const N = Math.min(500, Math.ceil((sb - sa) / 0.04)), sur: Pt[] = [], rail: Pt[] = [], idx: number[] = [];    for (let i = 0; i <= N; i++) { const x = sa + ((sb - sa) * i) / N, e = at(c, x), a = Math.atan(e.d1); sur.push([SX(x), SY(e.y)]); rail.push([SX(x - Math.sin(a) * 0.45), SY(e.y + Math.cos(a) * 0.45)]); idx.push(Math.round((x - c.x0) / DX)); }    if (c.hill) { const hp: Pt[] = sur.map(([x, y]): Pt => [x, y + 0.3 * kc]); hp.push([sur[N][0], bot + 10], [sur[0][0], bot + 10]); api.poly(hp, { close: true, fill: "paper", stroke: false }); api.hatch(hp, { gap: u(8), angle: 45, w: u(0.6) }); api.poly(hp.slice(0, N + 1), { w: u(1.2) }); }    else for (let x = Math.ceil((sa - c.x0 - 2) / 3) * 3 + c.x0 + 2; x < sb; x += 3) { const y = at(c, x).y; api.rect(SX(x) - 0.06 * kc, SY(y) + u(3), 0.12 * kc, Math.max(0, gy - SY(y)), { fill: "paper", w: u(1) }); }    const side: Pt[] = [...sur, ...rail.slice().reverse()];    api.poly(side, { close: true, fill: "paper", stroke: false });    api.hatch(side, { gap: u(5), angle: 90, w: u(0.5) });    api.poly(rail, { w: u(1.4) });    band(api, s, sur, idx, u(4), 3);    surface(api, s, sur, idx, u(1.8), u(2.4));  }  if (xa < c.x0) { const d0 = SX(c.x0 - 1.3), dy = SY(c.y0); api.rect(d0, dy, SX(c.x0) - d0, 0.15 * kc, { fill: "ink", stroke: false }); api.line(d0, dy, d0, dy - kc, { w: u(1) }); api.line(d0, dy - kc, SX(c.x0 - 0.1), dy - kc, { w: u(1) }); }  // 飛んだ道すじ  if (r.an > 1) {    const q: number[] = [];    for (let i = 0; i <= r.an; i++) {      if (i === r.an || Number.isNaN(r.ax[i])) { if (i === r.an && r.mode === 2) q.push(SX(r.x), SY(r.y)); if (q.length > 2) api.poly(q, { w: u(1), dash: [u(2), u(3)] }); q.length = 0; }      else q.push(SX(r.ax[i]), SY(r.ay[i]));    }  }  // 曲がりの円(その場所の曲がりの半径 R)  if (r.mode === 1 && Math.abs(r.kap) > 1 / 15) {    const R = 1 / Math.abs(r.kap), sg = Math.sign(r.kap), a = r.ang, ox = r.x - Math.sin(a) * R * sg, oy = r.y + Math.cos(a) * R * sg;    const ca = Math.atan2(r.y - oy, r.x - ox), span = Math.min(1.3, (b.w * 0.7) / (R * kc)), q: Pt[] = [];    for (let i = 0; i <= 40; i++) { const t = ca - span + (2 * span * i) / 40; q.push([SX(ox + Math.cos(t) * R), SY(oy + Math.sin(t) * R)]); }    api.poly(q, { w: u(1), dash: [u(5), u(4)] });    api.line(SX(ox), SY(oy), SX(r.x), SY(r.y), { w: u(0.8), dash: [u(2), u(3)] });    api.circle(SX(ox), SY(oy), u(2.5), { fill: "ink", stroke: false });    // 半径の札: 触れている点から中心へ 1.6 m(または半径の 6 割)の所。右上の G の数字とは重ねない    const f = Math.min(0.6, 1.6 / R), lx = SX(r.x + (ox - r.x) * f) + u(6), ly = SY(r.y + (oy - r.y) * f);    tag(api, `R ${fmt(R, 1)} m`, clamp(lx, b.x + u(30), b.x + b.w - u(150)), clamp(ly, b.y + u(48), bot - u(10)), fs, "left", b);  }  // 子どもと、滑る人  const fig = (rr: Rider) => { if (!rr.mode) return; const X = SX(rr.x), Y = SY(rr.y); if (X < b.x - 2 * kc || X > b.x + b.w + 2 * kc) return; person(api, X, Y, rr.mode >= 3 ? 0 : rr.ang, kc, rr === s.kid, rr.mode === 2); };  fig(s.kid);  const sp = r.mode === 2 ? Math.hypot(r.vx, r.vy) : Math.abs(r.v);  {    const X = SX(r.mode ? r.x : c.x0), Y = SY(r.mode ? r.y : c.y0), a = r.mode >= 3 ? 0 : r.mode ? r.ang : c.pa[0], tx = Math.cos(a), ty = -Math.sin(a), nx = -Math.sin(a), ny = -Math.cos(a);    const dir = r.mode === 2 ? 1 : Math.sign(r.v || 1);    for (let i = 0; i < 3 && sp > 0.5; i++) { const o = (0.35 + 0.22 * i) * kc, l = Math.min(b.w * 0.35, sp * u(5)); api.line(X - tx * dir * kc * 0.3 + nx * o, Y - ty * dir * kc * 0.3 + ny * o, X - tx * dir * (kc * 0.3 + l) + nx * o, Y - ty * dir * (kc * 0.3 + l) + ny * o, { w: u(0.7), dash: [u(6), u(4)] }); }    person(api, X, Y, a, kc, false, r.mode === 2);    // 力の矢印(重さ mg を決まった長さにして、ほかの力は mg との比で描く)    const L0 = clamp(b.h * 0.15, u(22), u(56)), hx = X + nx * 0.2 * kc, hy = Y + ny * 0.2 * kc, maxL = b.h * 0.42;    const arr = (x: number, y: number, dx: number, dy: number, g: number, t: string, w: number) => {      const len = Math.min(maxL, g * L0); if (len < u(3)) return;      api.arrow(x, y, x + dx * len, y + dy * len, { w, head: Math.min(u(9), Math.max(u(4), len * 0.4)) });      if (g * L0 > maxL) { const mxp = x + dx * len * 0.55, myp = y + dy * len * 0.55; api.line(mxp - dy * u(5), myp + dx * u(5), mxp + dy * u(5), myp - dx * u(5), { w: u(2.4), stroke: "paper" }); }      tag(api, t, x + dx * len + (dx >= -0.2 ? u(5) : -u(5)), y + dy * len + (dy > 0.3 ? u(13) : -u(4)), fs, dx >= -0.2 ? "left" : "right", b);    };    arr(hx, hy, 0, 1, 1, "mg", u(2));    if (r.mode === 0) arr(hx, hy, nx, ny, Math.cos(a), `N ${fmt(Math.cos(a), 2)} mg`, u(2.4));    if (r.mode === 1 || r.mode === 3) arr(hx, hy, r.mode === 3 ? 0 : nx, r.mode === 3 ? -1 : ny, r.gv, `N ${fmt(r.gv, 2)} mg`, u(2.4));    if ((r.mode === 1 || r.mode === 3) && r.fr > 0.05 && sp > 0.02) arr(X - tx * dir * 0.15 * kc, Y - ty * dir * 0.15 * kc, -tx * dir, -ty * dir, r.fr / G0, "μN", u(1.8));    const vdx = r.mode === 2 ? r.vx / (sp || 1) : tx * dir, vdy = r.mode === 2 ? -r.vy / (sp || 1) : ty * dir;    if ((r.dr / G0) * L0 > u(3)) arr(X + nx * 0.6 * kc, Y + ny * 0.6 * kc, -vdx, -vdy, r.dr / G0, "空気", u(1.4));    if (r.mode === 2) tag(api, "浮いている: N = 0", X + u(14), Y - 1.25 * kc, fs, "left", b);    if (r.mode === 0) tag(api, "スタートで待っている", X + u(14), Y - 1.25 * kc, fs, "left", b);  }  // G の数字と目盛り  const gTop = clamp(Math.ceil(Math.max(s.pred.maxG, r.maxG, 2) + 0.15), 2, 8), mX = b.x + b.w - u(16), m0 = b.y + u(48), m1 = bot - u(14);  api.rect(mX - u(4), m0, u(8), m1 - m0, { fill: "paper", w: u(1) });  const fh = (clamp(r.G, 0, gTop) / gTop) * (m1 - m0); if (fh > 0.5) api.rect(mX - u(4), m1 - fh, u(8), fh, { fill: "ink", stroke: false });  for (let g = 0; g <= gTop; g++) { const yy = m1 - (g / gTop) * (m1 - m0); api.line(mX - u(8), yy, mX - u(4), yy, { w: u(0.8) }); }  const ym = m1 - (clamp(r.maxG, 0, gTop) / gTop) * (m1 - m0); api.poly([[mX + u(5), ym], [mX + u(10), ym - u(4)], [mX + u(10), ym + u(4)]], { close: true, fill: "ink", stroke: false });  api.label(`${fmt(r.G, 2)} G`, mX - u(14), b.y + u(32), { size: u(21), font: "mono", align: "right", weight: 600 });  tag(api, `近くで見る  ${fmt(sp * KMH, 0)} km/h`, b.x + u(8), b.y + u(16), fs, "left", b);  ctx.restore();  api.rect(b.x, b.y, b.w, b.h, { w: u(1) });} function drawEnergy(api: Api, s: S, b: Box) {  const u = api.u, r = s.r, fs = u(10.5), m = r.m;  const sp = r.mode === 2 ? Math.hypot(r.vx, r.vy) : Math.abs(r.v), ys = r.mode ? r.ys : s.c.y0, v0 = r.mode ? r.v0 : 0;  const tot = m * (G0 * ys + 0.5 * v0 * v0), parts = [0.5 * m * sp * sp, m * r.eF, m * r.eA, m * r.eI];  const kj = (e: number) => (e >= 1000 ? `${fmt(e / 1000, 2)} kJ` : `${fmt(e, 0)} J`);  tag(api, `高さから生まれたエネルギーの行き先(全部で ${kj(tot)})`, b.x + u(2), b.y + u(12), fs, "left", b);  const bx = b.x + u(2), bw = b.w - u(4), by = b.y + u(22), bh = clamp(b.h * 0.2, u(12), u(24));  api.rect(bx, by, bw, bh, { w: u(1) });  let x = bx;  const fills = [(q: Pt[]) => api.poly(q, { close: true, fill: "ink", stroke: false }), (q: Pt[]) => api.hatch(q, { gap: u(3), angle: 45, w: u(0.8) }), (q: Pt[]) => api.dots(q, { gap: u(3), r: u(0.8) }), (q: Pt[]) => { api.hatch(q, { gap: u(3), angle: 45, w: u(0.7) }); api.hatch(q, { gap: u(3), angle: -45, w: u(0.7) }); }];  parts.forEach((e, i) => { const w = tot > 0 ? clamp(e / tot, 0, 1) * bw : 0; if (w > 0.5) { const q: Pt[] = [[x, by], [x + w, by], [x + w, by + bh], [x, by + bh]]; fills[i](q); api.line(x + w, by, x + w, by + bh, { w: u(0.8) }); } x += w; });  const names = ["速さ(運動)", "摩擦の熱", "空気の抵抗", "着地の衝撃"], two = b.w > u(300), rowH = u(16), y0 = by + bh + u(16);  names.forEach((t, i) => {    const cxp = bx + (two ? (i % 2) * (bw / 2) : 0), cyp = y0 + (two ? Math.floor(i / 2) : i) * rowH;    if (cyp > b.y + b.h - u(2)) return;    const q: Pt[] = [[cxp, cyp - u(9)], [cxp + u(12), cyp - u(9)], [cxp + u(12), cyp], [cxp, cyp]];    api.rect(cxp, cyp - u(9), u(12), u(9), { fill: "paper", w: u(0.8) }); fills[i](q);    api.text(`${t}  ${kj(parts[i])}`, cxp + u(17), cyp, { size: fs, font: "mono" });  });  const rem = Math.max(0, tot - parts.reduce((a, e) => a + e, 0)), yl = y0 + (two ? 2 : 4) * rowH;  if (yl < b.y + b.h - u(2)) api.text(`まだ高さに残っている分  ${kj(rem)}(右の白い所)`, bx, yl, { size: fs, font: "mono" });} function drawSpiral(api: Api, s: S, b: Box, L: Lay) {  const u = api.u, c = s.c, r = s.r, sp = c.spiral!, fs = u(10.5), side = b.w > b.h * 1.25;  const A: Box = side ? { x: b.x, y: b.y, w: b.w * 0.52, h: b.h } : { x: b.x, y: b.y, w: b.w, h: b.h * 0.55 };  const Bx: Box = side ? { x: b.x + b.w * 0.52, y: b.y, w: b.w * 0.48, h: b.h } : { x: b.x, y: b.y + b.h * 0.55, w: b.w, h: b.h * 0.45 };  // 上から見た形  const cx = A.x + A.w / 2, cy = A.y + A.h / 2 + u(6), kp = s.planK || (Math.min(A.w, A.h - u(20)) / 2 - u(10)) / (sp.R + 0.8);  const ctx = api.ctx;  ctx.save(); ctx.beginPath(); ctx.rect(A.x, A.y + u(18), A.w, A.h - u(18)); ctx.clip();  const q: number[] = []; for (let i = 0; i < c.n; i += 2) q.push(cx + (c.qx[i] - c.qc[0]) * kp, cy - (c.qy[i] - c.qc[1]) * kp);  api.poly(q, { w: u(2) });  const ri = clamp(Math.round(((r.mode ? r.x : c.x0) - c.x0) / DX), 0, c.n - 1), rx = cx + (c.qx[ri] - c.qc[0]) * kp, ry = cy - (c.qy[ri] - c.qc[1]) * kp;  if (s.kid.mode) { const ki = clamp(Math.round((s.kid.x - c.x0) / DX), 0, c.n - 1); api.circle(cx + (c.qx[ki] - c.qc[0]) * kp, cy - (c.qy[ki] - c.qc[1]) * kp, u(3), { fill: "paper", w: u(1) }); }  api.circle(rx, ry, u(4.5), { fill: "ink", stroke: false });  if (r.gl > 0.02 && r.mode === 1) { const dx = cx - rx, dy = cy - ry, d = Math.hypot(dx, dy) || 1, len = Math.min(sp.R * kp * 0.9, r.gl * u(34)); api.arrow(rx, ry, rx + (dx / d) * len, ry + (dy / d) * len, { w: u(2), head: u(7) }); }  api.circle(cx, cy, u(2), { fill: "ink", stroke: false });  ctx.restore();  const hx = cx + sp.R * kp, hy = cy, hs = Math.max(u(9), 11);  L.plan = { cx, cy, k: kp, hx, hy };  api.line(cx, cy, hx, hy, { w: u(0.8), dash: [u(2), u(3)] });  api.rect(hx - hs / 2, hy - hs / 2, hs, hs, { fill: s.grab?.kind === "R" ? "ink" : "paper", w: u(1.3) });  tag(api, `R_h ${fmt(sp.R, 1)} m`, hx + hs, hy - u(8), fs, "left", A);  tag(api, "上から見た形", A.x + u(2), A.y + u(12), fs, "left", A);  tag(api, `横向きの G ${fmt(r.gl, 2)}`, A.x + u(2), A.y + u(27), fs, "left", A);  // 断面(進む向きから見る。曲がりの外側を右に)  const rr = Math.min(Bx.w * 0.3, Bx.h * 0.3), ux = Bx.x + Bx.w / 2, uy = Bx.y + Bx.h * 0.42 + u(4), wallTop = uy - rr * 0.55;  const arc: Pt[] = [[ux - rr, wallTop]]; for (let i = 0; i <= 30; i++) { const t = Math.PI - (Math.PI * i) / 30; arc.push([ux + rr * Math.cos(t), uy + rr * Math.sin(t)]); } arc.push([ux + rr, wallTop]);  const shell: Pt[] = [...arc, ...arc.slice().reverse().map(([x, y]): Pt => [ux + (x - ux) * 1.14, y < uy ? y : uy + (y - uy) * 1.14])];  api.hatch(shell, { gap: u(3), angle: 45, w: u(0.6) });  api.poly(arc, { w: u(2.2) });  const phi = r.mode === 1 ? Math.min(r.phi, 1.75) : 0, br = rr * 0.16, rb = rr - br;  const bxp = phi <= Math.PI / 2 ? ux + rb * Math.sin(phi) : ux + rb, byp = phi <= Math.PI / 2 ? uy + rb * Math.cos(phi) : uy - (phi - Math.PI / 2) * rb;  api.line(ux, uy, ux, uy + rr, { w: u(0.6), dash: [u(2), u(3)] });  api.line(ux, uy, bxp, byp, { w: u(0.6), dash: [u(2), u(3)] });  if (phi > 0.05) { const aq: Pt[] = []; for (let i = 0; i <= 12; i++) { const t = (Math.min(phi, Math.PI / 2) * i) / 12; aq.push([ux + rr * 0.35 * Math.sin(t), uy + rr * 0.35 * Math.cos(t)]); } api.poly(aq, { w: u(1) }); }  api.circle(bxp, byp, br, { fill: "ink", stroke: false });  if (r.mode === 1) { const len = Math.min(rr * 0.9, r.G * u(20)), dx = ux - bxp, dy = uy - byp, d = Math.hypot(dx, dy) || 1; api.arrow(bxp, byp, bxp + (dx / d) * len, byp + (dy / d) * len, { w: u(1.8), head: u(6) }); }  api.arrow(bxp, byp, bxp, byp + u(20), { w: u(1.2), head: u(5) });  tag(api, "溝の断面", Bx.x + u(6), Bx.y + u(12), fs, "left", Bx);  tag(api, "外側", ux + rr * 1.15, wallTop - u(4), fs, "center", Bx);  tag(api, `傾き φ ${fmt(phi * DEG, 0)}°`, ux - rr * 0.1, uy + rr + u(18), fs, "center", Bx);} function draw(api: Api, s: S) {  const L = (s.lay = layout(api, s));  api.clear();  drawOverview(api, s, L);  drawGraph(api, s, L);  drawClose(api, s, L);  api.rect(L.pn.x, L.pn.y, L.pn.w, L.pn.h, { w: api.u(1) });  const inner = { x: L.pn.x + api.u(6), y: L.pn.y + api.u(4), w: L.pn.w - api.u(12), h: L.pn.h - api.u(8) };  if (s.c.spiral) drawSpiral(api, s, inner, L); else drawEnergy(api, s, inner);}

拡大の段では、重さの矢印を決まった長さにし、・摩擦・空気の抵抗をその比で描く。破線の円は、人のいる所の曲がりの円で、半径が15 mより小さいときだけ出す。エネルギーの帯は、スタートからの高さの差と最初の運動エネルギーの合計が、速さ・摩擦の熱・空気・着地にどう分かれたかを、刻みごとに足し合わせた値で示している。

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

プログラム全体を見る(src/sims/long-slide.ts・789 行)

読み込んでいます

姉妹サイト

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