このシミュレーションは、1/240秒ごとに「人がいる場所の傾きと曲がりを調べる → 面が押す力NとGを求める → 摩擦と空気の抵抗を引いて速さと位置を進める」をくり返している。Nが0を下回ったら面から離れて飛ばし、面か地面に戻ったら着地の衝撃を加える。場所ごとのG(滑り台の線の太さと網点)は、形や条件が変わるたびに、同じ計算を画面には出さずに最後まで走らせて求めている。
滑り台の形は、四角の取っ手の点を通る自然3次スプラインで作る。点と点のあいだを3次式でつなぎ、つなぎ目で傾きだけでなく曲がり(2階微分)もそろえる。曲がりがとぎれないので、本文のv2/Rの項が急に飛ばず、Gもなめらかに変わる。各点の2階微分Mは、3重対角の連立方程式を前から消して後ろから代入する方法(トーマス法)で解いている。
計算は、今の力で速さを進めてから位置を進める半陰的オイラー法で、刻みは1/240秒と細かくしている。半径2.8 mの谷を秒速9 mで通るとき、1刻みに進むのは4 cmほどで、曲がりの変化を十分に追える。刻みは画面のコマ数によらず一定なので、どの端末でも同じ滑りになる。
指を置いた所に取っ手があれば、その点を動かす。両端の点は高さだけ、間の点は隣の点との間に0.8 mを残す範囲で動く。点を動かすたびにスプラインを作り直し、先回りの計算もやり直す。「+」の印をたたくと、その区間の真ん中に、形を変えずに点を足す。滑り台の線の近くをたたくと、そこから滑らせる。
上の段の全体図とグラフは、水平の位置の目盛りをそろえてあるので、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);}