27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
31:28Is this company reselling GPT Sol 6.1 at a loss (for non-agentic/text-only)?🤔 Is this sustainable?
Oct 20, 2026 · YouTube
17:58Nemotron 3.5 Super VL vs Opus 5.5: Testing NVIDIA’s new coding juggernaut
Oct 15, 2026 · YouTube
16:04Testing a *massive* new open-weights model 🤯: Muse Spark Open (vs. Nemotron, Opus 5.5, and more)
Oct 15, 2026 · YouTube
16:43Gemini 4 Argon: Great 🐈 Animation, but a broken escalator. (Coding challenge vs Opus 5.5, Sol 6.1)
Oct 13, 2026 · YouTube
16:37Meta's new "Muse Wonder" AI is impressive... but can it compete with Anthropic's models?
Oct 12, 2026 · YouTube
11:12WOW... Haiku 5.5 is very impressive. RIP OpenAI?
Oct 7, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
22:02Is Pareto 26.10 Preview Just Routing to GPT Sol? I Tested It Against Sol 6.1 Pro
Oct 1, 2026 · YouTube
11:22I Tested Sonnet 5.5 Against Opus 5.5. Is It Actually Cheaper?
Sep 28, 2026 · YouTube
19:21I Tested the Stealth "Space Bunny Alpha" Model Before It Went Offline
Sep 23, 2026 · YouTube
You build physics toys, and you have held an umbrella in a gale.
Build me a single HTML page: a person holding an umbrella in wind that keeps getting stronger.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.One paragraph, before the file: what the canopy is made of in your code, what inversion is as a state change, and what decides when it happens.
The single fenced html block.
Where it looks right and where it looks wrong. Everyone who has held an umbrella knows what one does in wind, so say what yours gets wrong before I find it.
The canopy is represented as a 2D cross-section of two articulated fiberglass ribs—windward and leeward—each modeled as a series of Verlet-integrated point masses linked by distance constraints, attached to a sliding runner via rigid stretcher struts and given curvature by angular restoring torques at the joints. There is no discrete boolean state flag for "inverted"; instead, inversion is a continuous geometric snap-through buckling event of the linkage itself. As aerodynamic pressure builds on the concave underside (when tilted into the wind) or suction pulls on the leeward fabric, the bending ribs push the stretchers toward a collinear dead-center alignment with the central shaft; once wind load forces a rib-stretcher joint past that over-center line, the slider's spring force and the rib's own internal bending stiffness flip sign relative to the shaft, violently driving the canopy into its stable upward-cupped inverted geometry until the wind drops enough—or catches the back side—to snap it back.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gale & Umbrella</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0d131a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #dbe4ee;
}
canvas { display: block; width: 100%; height: 100%; }
#hud {
position: absolute; top: 20px; left: 20px;
background: rgba(12, 18, 26, 0.78);
border: 1px solid rgba(160, 190, 220, 0.22);
border-radius: 10px;
padding: 16px 20px;
width: 310px;
backdrop-filter: blur(6px);
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
}
.speed-row {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 4px;
}
.speed-val {
font-size: 38px; font-weight: 700; letter-spacing: -1px;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.speed-unit { font-size: 14px; font-weight: 500; color: #8fa6bf; margin-left: 4px; }
.beaufort {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
color: #6fb1fc; font-weight: 600; margin-bottom: 14px;
}
.control-group { margin-bottom: 12px; }
.control-label {
display: flex; justify-content: space-between;
font-size: 12px; color: #9cb0c9; margin-bottom: 6px;
}
input[type="range"] {
width: 100%; accent-color: #5ca4f8; cursor: pointer;
height: 6px; border-radius: 3px; background: #243242;
}
.btn-row { display: flex; gap: 8px; margin-top: 10px; }
button {
flex: 1; padding: 7px 10px;
background: #1d2a3a; color: #d0dfef;
border: 1px solid rgba(160, 190, 220, 0.25);
border-radius: 6px; font-size: 12px; font-weight: 600;
cursor: pointer; transition: background 0.15s, border-color 0.15s;
}
button:hover { background: #293b52; border-color: rgba(160, 190, 220, 0.45); }
button.active { background: #2b578a; border-color: #6fb1fc; color: #fff; }
.hint {
margin-top: 10px; font-size: 11px; color: #6e849e; line-height: 1.4;
}
#status-pill {
display: inline-block; padding: 2px 7px; border-radius: 4px;
font-size: 10px; font-weight: 700; letter-spacing: 0.05em;
text-transform: uppercase; background: #1f3347; color: #90b8e0;
}
</style>
</head>
<body>
<canvas id="sim"></canvas>
<div id="hud">
<div class="speed-row">
<div><span id="wind-num" class="speed-val">8</span><span class="speed-unit">km/h</span></div>
<span id="status-pill">HOLDING</span>
</div>
<div id="beaufort-label" class="beaufort">Force 2 · Light Breeze</div>
<div class="control-group">
<div class="control-label">
<span>Base Wind Speed</span>
<span>← / → keys or drag</span>
</div>
<input type="range" id="wind-slider" min="0" max="135" step="0.5" value="8">
</div>
<div class="btn-row">
<button id="btn-auto" class="active">Auto-Rise: ON</button>
<button id="btn-reset">New Umbrella (R)</button>
</div>
<div class="hint">
Wind gusts around the base speed. Past ~55 km/h the frame risks snapping inside out; past ~95 km/h grip fails.
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('sim');
const ctx = canvas.getContext('2d');
const windSlider = document.getElementById('wind-slider');
const windNumEl = document.getElementById('wind-num');
const beaufortEl = document.getElementById('beaufort-label');
const statusPill = document.getElementById('status-pill');
const btnAuto = document.getElementById('btn-auto');
const btnReset = document.getElementById('btn-reset');
let W = window.innerWidth;
let H = window.innerHeight;
let groundY = H * 0.82;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
groundY = H * 0.82;
}
window.addEventListener('resize', resize);
resize();
// --- Wind State ---
let baseWindKmh = 8;
let actualWindKmh = 8;
let autoRise = true;
let gustTime = 0;
windSlider.addEventListener('input', () => {
baseWindKmh = parseFloat(windSlider.value);
setAutoRise(false);
});
function setAutoRise(val) {
autoRise = val;
btnAuto.textContent = autoRise ? 'Auto-Rise: ON' : 'Auto-Rise: OFF';
btnAuto.classList.toggle('active', autoRise);
}
btnAuto.addEventListener('click', () => setAutoRise(!autoRise));
btnReset.addEventListener('click', resetUmbrella);
const keys = {};
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'KeyR') resetUmbrella();
if (e.code === 'Space') { e.preventDefault(); setAutoRise(!autoRise); }
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
function getBeaufort(kmh) {
if (kmh < 2) return 'Force 0 · Calm';
if (kmh < 6) return 'Force 1 · Light Air';
if (kmh < 12) return 'Force 2 · Light Breeze';
if (kmh < 20) return 'Force 3 · Gentle Breeze';
if (kmh < 29) return 'Force 4 · Moderate Breeze';
if (kmh < 39) return 'Force 5 · Fresh Breeze';
if (kmh < 50) return 'Force 6 · Strong Breeze';
if (kmh < 62) return 'Force 7 · Near Gale';
if (kmh < 75) return 'Force 8 · Gale';
if (kmh < 89) return 'Force 9 · Strong Gale';
if (kmh < 103) return 'Force 10 · Storm';
if (kmh < 118) return 'Force 11 · Violent Storm';
return 'Force 12 · Hurricane Force';
}
// --- Person & Umbrella Physics ---
// Coordinate system: Wind blows LEFT to RIGHT (+X).
// Person stands around X = W * 0.36, facing LEFT (-X) into the wind!
const SCALE = 1.15; // pixels per cm roughly
const person = {
anchorX: 0,
leanAngle: 0, // radians, negative = leaning left into wind
leanVel: 0,
braceStep: 0, // how wide feet are planted
gripFatigue: 0, // 0..1, releases at 1
recoil: 0, // kickback when umbrella flies away
handX: 0,
handY: 0,
shoulderX: 0,
shoulderY: 0,
hipX: 0,
hipY: 0,
headX: 0,
headY: 0,
frontFootX: 0,
backFootX: 0,
coatNodes: []
};
// Coat tail Verlet strip
function initCoat() {
person.coatNodes = [];
for (let i = 0; i < 5; i++) {
person.coatNodes.push({ x: 0, y: 0, ox: 0, oy: 0 });
}
}
initCoat();
// Umbrella rigid shaft + flexible Verlet canopy
const umb = {
attached: true,
inverted: false,
x: 0, y: 0, // handle position
vx: 0, vy: 0,
angle: -0.35, // angle from vertical (0 = straight up, negative = tilted left into wind)
omega: 0,
shaftLen: 74 * SCALE,
runnerPos: 0.68, // fraction along shaft from handle (0..0.92)
runnerVel: 0,
ribs: [] // 2 main ribs in cross-section (-1 windward/left, +1 leeward/right) + 1 middle visual rib
};
const RIB_SEGS = 5;
const SEG_LEN = 14.5 * SCALE;
const STRETCHER_LEN = 31 * SCALE;
function makeRib(side) {
const pts = [];
for (let i = 0; i <= RIB_SEGS; i++) {
pts.push({ x: 0, y: 0, ox: 0, oy: 0, mass: i === RIB_SEGS ? 0.6 : 1.0 });
}
return { side, pts };
}
function resetUmbrella() {
person.gripFatigue = 0;
person.recoil = 0;
umb.attached = true;
umb.inverted = false;
umb.angle = -Math.min(0.65, 0.12 + actualWindKmh * 0.006);
umb.omega = 0;
umb.vx = 0;
umb.vy = 0;
umb.runnerPos = 0.68;
umb.runnerVel = 0;
updatePersonKinematics(0.016);
umb.x = person.handX;
umb.y = person.handY;
const sinA = Math.sin(umb.angle);
const cosA = Math.cos(umb.angle);
const notchX = umb.x + sinA * umb.shaftLen;
const notchY = umb.y - cosA * umb.shaftLen;
// Normal vector to shaft pointing right
const nx = cosA;
const ny = sinA;
umb.ribs = [makeRib(-1), makeRib(1)];
for (const rib of umb.ribs) {
for (let i = 0; i <= RIB_SEGS; i++) {
const t = i / RIB_SEGS;
// Natural dome curve downward from notch
const spread = rib.side * Math.sin(t * 1.35) * (RIB_SEGS * SEG_LEN * 0.78);
const drop = (1 - Math.cos(t * 1.35)) * (RIB_SEGS * SEG_LEN * 0.55);
const px = notchX + nx * spread - sinA * drop;
const py = notchY + ny * spread + cosA * drop;
rib.pts[i].x = rib.pts[i].ox = px;
rib.pts[i].y = rib.pts[i].oy = py;
}
}
}
function updatePersonKinematics(dt) {
const targetAnchorX = W * 0.34;
person.anchorX = targetAnchorX;
// Wind force in normalized units
const windMps = actualWindKmh / 3.6;
const dynPress = 0.5 * 1.225 * windMps * windMps; // Pascals (~0 to 900)
// Footing widens as wind rises
const targetBrace = Math.min(1, actualWindKmh / 75);
person.braceStep += (targetBrace - person.braceStep) * Math.min(1, dt * 4);
const stanceWidth = (24 + person.braceStep * 42) * SCALE;
person.frontFootX = person.anchorX - stanceWidth * 0.52;
person.backFootX = person.anchorX + stanceWidth * 0.48;
// Target lean into the wind (-X direction)
let desiredLean = -Math.atan2(dynPress * 0.048, 70);
if (umb.attached) {
// Extra counter-lean against umbrella drag
const umbPull = umb.inverted ? dynPress * 0.032 : dynPress * 0.055;
desiredLean -= Math.atan2(umbPull, 85);
}
desiredLean = Math.max(-0.62, desiredLean);
// Recoil spring when umbrella suddenly detaches
if (person.recoil > 0) {
person.recoil = Math.max(0, person.recoil - dt * 2.2);
}
const totalTargetLean = desiredLean - person.recoil * 0.35;
const springK = 26;
const damp = 7.5;
const acc = (totalTargetLean - person.leanAngle) * springK - person.leanVel * damp;
person.leanVel += acc * dt;
person.leanAngle += person.leanVel * dt;
// Build skeleton from ground up
const legLen = 78 * SCALE;
const torsoLen = 58 * SCALE;
// Crouch slightly in heavy wind
const crouch = person.braceStep * 11 * SCALE;
person.hipX = person.anchorX + Math.sin(person.leanAngle) * (legLen - crouch);
person.hipY = groundY - Math.cos(person.leanAngle) * (legLen - crouch);
const torsoAngle = person.leanAngle * 1.18 - (umb.attached ? 0.06 : -0.05);
person.shoulderX = person.hipX + Math.sin(torsoAngle) * torsoLen;
person.shoulderY = person.hipY - Math.cos(torsoAngle) * torsoLen;
person.headX = person.shoulderX + Math.sin(torsoAngle * 0.9) * (20 * SCALE);
person.headY = person.shoulderY - Math.cos(torsoAngle * 0.9) * (20 * SCALE);
// Hand positioning: braces closer to chest/face in strong wind, gets pulled forward (+X) by gusts
if (umb.attached) {
const windPullX = Math.min(34 * SCALE, dynPress * 0.055);
const windLiftY = umb.inverted ? -Math.min(22 * SCALE, dynPress * 0.035) : Math.min(10 * SCALE, dynPress * 0.015);
const baseReachX = -32 * SCALE + windPullX;
const baseReachY = -6 * SCALE + person.braceStep * 10 * SCALE + windLiftY;
const targetHX = person.shoulderX + baseReachX;
const targetHY = person.shoulderY + baseReachY;
person.handX += (targetHX - person.handX) * Math.min(1, dt * 18);
person.handY += (targetHY - person.handY) * Math.min(1, dt * 18);
} else {
// Arms drop / shield face after losing umbrella
const targetHX = person.shoulderX - 18 * SCALE;
const targetHY = person.shoulderY + 8 * SCALE;
person.handX += (targetHX - person.handX) * Math.min(1, dt * 6);
person.handY += (targetHY - person.handY) * Math.min(1, dt * 6);
}
// Coat tail physics
const coatRootX = person.hipX + 6 * SCALE;
const coatRootY = person.hipY + 2 * SCALE;
person.coatNodes[0].x = coatRootX;
person.coatNodes[0].y = coatRootY;
const coatSeg = 11 * SCALE;
for (let i = 1; i < person.coatNodes.length; i++) {
const n = person.coatNodes[i];
const vx = (n.x - n.ox) * 0.92;
const vy = (n.y - n.oy) * 0.92;
n.ox = n.x; n.oy = n.y;
const flutter = Math.sin(gustTime * 18 + i * 1.7) * windMps * 0.08;
n.x += vx + (windMps * 0.42) * dt * 12;
n.y += vy + (9.8 * 0.9 + flutter) * dt * 10;
}
for (let iter = 0; iter < 4; iter++) {
person.coatNodes[0].x = coatRootX;
person.coatNodes[0].y = coatRootY;
for (let i = 0; i < person.coatNodes.length - 1; i++) {
const a = person.coatNodes[i];
const b = person.coatNodes[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 0.001;
const diff = (d - coatSeg) / d;
if (i === 0) {
b.x -= dx * diff;
b.y -= dy * diff;
} else {
a.x += dx * diff * 0.5;
a.y += dy * diff * 0.5;
b.x -= dx * diff * 0.5;
b.y -= dy * diff * 0.5;
}
}
}
}
function stepUmbrellaPhysics(dt) {
const substeps = 8;
const sdt = dt / substeps;
const windMps = actualWindKmh / 3.6;
// Wind vector (mostly +X, slight updraft turbulence during gusts)
const updraft = Math.sin(gustTime * 3.9) * Math.cos(gustTime * 1.7) * windMps * 0.16;
const windVX = windMps * 18; // scaled to pixel space
const windVY = -Math.abs(windMps) * 1.6 + updraft * 14;
for (let step = 0; step < substeps; step++) {
const sinA = Math.sin(umb.angle);
const cosA = Math.cos(umb.angle);
// Shaft unit vector from handle to tip: (sinA, -cosA)
const sx = sinA;
const sy = -cosA;
// Right-hand normal to shaft: (cosA, sinA)
const nx = cosA;
const ny = sinA;
if (umb.attached) {
umb.x = person.handX;
umb.y = person.handY;
}
const notchX = umb.x + sx * umb.shaftLen;
const notchY = umb.y + sy * umb.shaftLen;
// Runner position along shaft
// Internal spring pushes runner toward 0.70 (open position) unless over-center
const runnerSpring = (0.70 - umb.runnerPos) * 95;
umb.runnerVel += runnerSpring * sdt;
umb.runnerVel *= 0.88;
umb.runnerPos += umb.runnerVel * sdt;
umb.runnerPos = Math.max(0.32, Math.min(0.95, umb.runnerPos));
const runnerX = umb.x + sx * (umb.shaftLen * umb.runnerPos);
const runnerY = umb.y + sy * (umb.shaftLen * umb.runnerPos);
let netAeroFX = 0;
let netAeroFY = 0;
let netShaftTorque = 0;
// 1. Integrate Rib Point Masses & Apply Aerodynamic + Bending Forces
for (const rib of umb.ribs) {
const pts = rib.pts;
pts[0].x = notchX;
pts[0].y = notchY;
pts[0].ox = notchX;
pts[0].oy = notchY;
for (let i = 1; i <= RIB_SEGS; i++) {
const p = pts[i];
const vx = (p.x - p.ox) * 0.975;
const vy = (p.y - p.oy) * 0.975;
p.ox = p.x;
p.oy = p.y;
// Gravity
const grav = 320 * sdt * sdt;
p.x += vx;
p.y += vy + grav;
}
// Intrinsic fiberglass rib curvature: wants to arch outward and slightly toward handle
// Express target direction of each segment in shaft-local frame, but lightly so it can buckle!
for (let i = 1; i <= RIB_SEGS; i++) {
const prev = pts[i - 1];
const curr = pts[i];
// Natural dome angle when uninverted
const frac = (i - 0.5) / RIB_SEGS;
const naturalArchAngle = frac * 1.15;
// Local desired vector: outward along rib.side * nx, downward along -sy
const desX = rib.side * nx * Math.cos(naturalArchAngle * 0.65) - sx * Math.sin(naturalArchAngle * 0.55);
const desY = rib.side * ny * Math.cos(naturalArchAngle * 0.65) - sy * Math.sin(naturalArchAngle * 0.55);
const stiff = 165 * sdt * sdt;
curr.x += (prev.x + desX * SEG_LEN - curr.x) * stiff;
curr.y += (prev.y + desY * SEG_LEN - curr.y) * stiff;
}
// Aerodynamic pressure on each fabric panel segment
for (let i = 0; i < RIB_SEGS; i++) {
const a = pts[i];
const b = pts[i + 1];
const segDX = b.x - a.x;
const segDY = b.y - a.y;
const segLen = Math.hypot(segDX, segDY) || 1;
// Panel velocity
const pvx = ((a.x - a.ox) + (b.x - b.ox)) * 0.5 / sdt;
const pvy = ((a.y - a.oy) + (b.y - b.oy)) * 0.5 / sdt;
const relWX = windVX - pvx;
const relWY = windVY - pvy;
// Outward-from-underside normal (pointing from concave inside toward top of dome)
// For left rib (side = -1), walking notch->tip goes left-down; normal pointing up-left is (+segDY, -segDX)*(-1)
const outNX = -rib.side * (segDY / segLen);
const outNY = rib.side * (segDX / segLen);
// Dot product of relative wind with outward normal:
// Negative dot = wind hitting the concave UNDERSIDE (high parachute drag, pushes toward inversion!)
// Positive dot = wind hitting convex TOPSIDE (pushes rib inward, plus leeward suction on downwind rib)
const relSpeed = Math.hypot(relWX, relWY);
if (relSpeed > 0.01) {
const dot = (relWX * outNX + relWY * outNY);
// Parachute amplification when wind scoops under the canopy
let coeff = dot < 0 ? 1.85 : 0.75;
// Crucial real-umbrella effect: aerodynamic suction + vortex shedding on leeward side
// and scoop lift under the windward rim when gusts shift angle of attack
let fx = outNX * (-dot) * relSpeed * coeff * 0.0026 * (i + 1) / RIB_SEGS;
let fy = outNY * (-dot) * relSpeed * coeff * 0.0026 * (i + 1) / RIB_SEGS;
// Direct form drag in wind direction
fx += relWX * relSpeed * 0.00065;
fy += relWY * relSpeed * 0.00065;
// At high wind (>52 km/h), turbulent buffeting scoops under windward lip and sucks leeward dome upward
if (windMps > 12) {
const scoop = Math.pow(windMps - 12, 1.85) * 0.95;
const gustPulse = 0.65 + 0.55 * Math.sin(gustTime * 6.3 + rib.side * 1.2);
// Pushes ribs along +sx (toward tip / inside-out!)
fx += sx * scoop * gustPulse * (i / RIB_SEGS);
fy += sy * scoop * gustPulse * (i / RIB_SEGS);
}
const accelScale = sdt * sdt;
a.x += fx * accelScale * 0.5;
a.y += fy * accelScale * 0.5;
b.x += fx * accelScale * 0.5;
b.y += fy * accelScale * 0.5;
netAeroFX += fx;
netAeroFY += fy;
// Torque around handle (umb.x, umb.y)
const midX = (a.x + b.x) * 0.5 - umb.x;
const midY = (a.y + b.y) * 0.5 - umb.y;
netShaftTorque += (midX * fy - midY * fx);
}
}
}
// 2. Distance Constraints (Rib segments + Stretcher struts from runner to rib joint 2)
for (let iter = 0; iter < 6; iter++) {
const curRunnerX = umb.x + sx * (umb.shaftLen * umb.runnerPos);
const curRunnerY = umb.y + sy * (umb.shaftLen * umb.runnerPos);
for (const rib of umb.ribs) {
const pts = rib.pts;
pts[0].x = notchX;
pts[0].y = notchY;
// Rib segment lengths
for (let i = 0; i < RIB_SEGS; i++) {
const a = pts[i];
const b = pts[i + 1];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 0.0001;
const diff = (d - SEG_LEN) / d;
if (i === 0) {
b.x -= dx * diff;
b.y -= dy * diff;
} else {
a.x += dx * diff * 0.5;
a.y += dy * diff * 0.5;
b.x -= dx * diff * 0.5;
b.y -= dy * diff * 0.5;
}
}
// Bending resistance between adjacent rib segments (keeps smooth fiberglass arc even when inverted!)
for (let i = 1; i < RIB_SEGS; i++) {
const p0 = pts[i - 1];
const p1 = pts[i];
const p2 = pts[i + 1];
const midX = (p0.x + p2.x) * 0.5;
const midY = (p0.y + p2.y) * 0.5;
// Push p1 slightly away from chord midpoint to preserve smoothness, without locking orientation
const dx = p1.x - midX;
const dy = p1.y - midY;
p1.x -= dx * 0.08;
p1.y -= dy * 0.08;
}
// Stretcher strut from runner to pts[2]
const joint = pts[2];
const sdx = joint.x - curRunnerX;
const sdy = joint.y - curRunnerY;
const sd = Math.hypot(sdx, sdy) || 0.0001;
const sdiff = (sd - STRETCHER_LEN) / sd;
joint.x -= sdx * sdiff * 0.78;
joint.y -= sdy * sdiff * 0.78;
// Reaction pulls runner along the shaft! This is what creates true mechanical over-center snap-through.
const reactionAlongShaft = (sdx * sx + sdy * sy) * sdiff * 0.22;
umb.runnerPos += reactionAlongShaft / umb.shaftLen;
umb.runnerPos = Math.max(0.32, Math.min(0.95, umb.runnerPos));
// Keep ribs on their own side of the shaft so they don't cross through metal rod
for (let i = 1; i <= RIB_SEGS; i++) {
const relX = pts[i].x - notchX;
const relY = pts[i].y - notchY;
const sideDist = (relX * nx + relY * ny) * rib.side;
const minClear = i * 2.2 * SCALE;
if (sideDist < minClear) {
const push = (minClear - sideDist);
pts[i].x += nx * rib.side * push;
pts[i].y += ny * rib.side * push;
}
}
}
}
// Detect geometric inversion from actual rib tip positions relative to notch along shaft axis!
let avgTipProj = 0;
for (const rib of umb.ribs) {
const tip = rib.pts[RIB_SEGS];
const rx = tip.x - notchX;
const ry = tip.y - notchY;
avgTipProj += (rx * sx + ry * sy);
}
avgTipProj *= 0.5;
umb.inverted = avgTipProj > 8 * SCALE;
// 3. Shaft Angular & Linear Dynamics
if (umb.attached) {
// Person tries to aim umbrella into the wind: ideal angle leans left (-0.25 to -0.78 rad)
const targetAngle = -Math.min(0.82, 0.16 + windMps * 0.022);
// Wrist torque has physiological limits! Strong gusts wrench the umbrella sideways (+angle)
const maxWristTorque = 42000;
let wristTorque = (targetAngle - umb.angle) * 68000 - umb.omega * 9500;
wristTorque = Math.max(-maxWristTorque, Math.min(maxWristTorque, wristTorque));
// Extra violent wrenching torque right as canopy catches wind or inverts
const aeroTorqueScaled = netShaftTorque * 1.35;
const angularAcc = (wristTorque + aeroTorqueScaled) / 2600;
umb.omega += angularAcc * sdt;
umb.omega *= 0.992;
umb.angle += umb.omega * sdt;
umb.angle = Math.max(-1.35, Math.min(1.45, umb.angle));
// Grip fatigue accumulation when wind > ~88 km/h (24.5 m/s) or violent wrenching
const totalPull = Math.hypot(netAeroFX, netAeroFY);
if (windMps > 24.5 || totalPull > 1650) {
const overload = Math.max(0, (windMps - 24.0) * 0.14) + Math.max(0, (totalPull - 1650) * 0.0005);
person.gripFatigue += overload * sdt;
} else {
person.gripFatigue = Math.max(0, person.gripFatigue - 0.25 * sdt);
}
if (person.gripFatigue >= 1.0) {
umb.attached = false;
person.recoil = 1.0;
umb.vx = windVX * 0.32 + 120;
umb.vy = -140 + windVY * 0.4;
umb.omega += 2.8;
}
} else {
// Free-flying umbrella carried away by the gale!
const mass = 2.4;
const ax = (netAeroFX * 0.55 + (windVX - umb.vx) * 3.8) / mass;
const ay = 360 + (netAeroFY * 0.55 + (windVY - umb.vy) * 3.8) / mass;
umb.vx += ax * sdt;
umb.vy += ay * sdt;
const dx = umb.vx * sdt;
const dy = umb.vy * sdt;
umb.x += dx;
umb.y += dy;
// Translate rib points along with free-body translation so Verlet doesn't fight flight
for (const rib of umb.ribs) {
for (let i = 0; i <= RIB_SEGS; i++) {
rib.pts[i].x += dx;
rib.pts[i].y += dy;
rib.pts[i].ox += dx;
rib.pts[i].oy += dy;
}
}
const tumbleTorque = netShaftTorque * 0.45 + Math.sin(umb.angle * 2 + gustTime * 5) * windMps * 45;
umb.omega += (tumbleTorque / 1800) * sdt;
umb.omega *= 0.995;
umb.angle += umb.omega * sdt;
// Bounce / scrape off ground
if (umb.y > groundY - 8) {
umb.y = groundY - 8;
umb.vy = -Math.abs(umb.vy) * 0.45;
umb.omega += (umb.vx * 0.008);
}
}
}
}
// --- Rain & Splash System ---
const MAX_RAIN = 420;
const raindrops = [];
const splashes = [];
for (let i = 0; i < MAX_RAIN; i++) {
raindrops.push({
x: Math.random() * W * 1.6 - W * 0.4,
y: Math.random() * H,
z: 0.4 + Math.random() * 0.8 // depth layer
});
}
function spawnSplash(x, y, vx, vy) {
if (splashes.length > 140) return;
splashes.push({
x, y,
vx: vx * 0.12 + (Math.random() - 0.5) * 90,
vy: -Math.random() * 110 - 30,
life: 1.0
});
}
function updateAndDrawRain(dt) {
const windMps = actualWindKmh / 3.6;
const baseFall = 640;
const baseDrift = windMps * 36;
// Gather canopy segments for rain collision if umbrella is on screen
const segs = [];
if (umb.x < W + 200 && umb.y > -200) {
for (const rib of umb.ribs) {
for (let i = 0; i < RIB_SEGS; i++) {
segs.push({
x1: rib.pts[i].x, y1: rib.pts[i].y,
x2: rib.pts[i + 1].x, y2: rib.pts[i + 1].y
});
}
}
}
ctx.lineCap = 'round';
// Intensity scales slightly with storminess
const activeCount = Math.floor(160 + Math.min(1, actualWindKmh / 90) * (MAX_RAIN - 160));
for (let i = 0; i < activeCount; i++) {
const r = raindrops[i];
const vx = baseDrift * r.z;
const vy = baseFall * (0.75 + r.z * 0.45);
const nx = r.x + vx * dt;
const ny = r.y + vy * dt;
let hit = false;
// Foreground drops collide with canopy and ground
if (r.z > 0.72 && segs.length > 0) {
// Quick bounding check around notch
const p0 = umb.ribs[0].pts[0];
if (Math.abs(nx - p0.x) < 120 * SCALE && Math.abs(ny - p0.y) < 120 * SCALE) {
for (let s = 0; s < segs.length; s++) {
const seg = segs[s];
const minX = Math.min(seg.x1, seg.x2) - 8;
const maxX = Math.max(seg.x1, seg.x2) + 8;
const minY = Math.min(seg.y1, seg.y2) - 8;
const maxY = Math.max(seg.y1, seg.y2) + 8;
if (nx >= minX && nx <= maxX && ny >= minY && ny <= maxY) {
hit = true;
if (Math.random() < 0.45) spawnSplash(nx, ny, vx, vy);
break;
}
}
}
}
if (!hit && ny >= groundY) {
hit = true;
if (r.z > 0.8 && Math.random() < 0.3) {
spawnSplash(nx, groundY, vx * 0.5, vy);
}
}
// Draw streak
const streakScale = 0.026;
ctx.strokeStyle = `rgba(185, 212, 240, ${0.14 + r.z * 0.26})`;
ctx.lineWidth = r.z * 1.35;
ctx.beginPath();
ctx.moveTo(r.x, r.y);
ctx.lineTo(r.x + vx * streakScale, r.y + vy * streakScale);
ctx.stroke();
if (hit || nx > W + 180 || ny > H + 50) {
// Respawn upwind or along top
if (Math.random() < Math.min(0.75, baseDrift / (baseDrift + baseFall))) {
r.x = -40 - Math.random() * 140;
r.y = Math.random() * groundY;
} else {
r.x = Math.random() * (W + 250) - 200;
r.y = -20 - Math.random() * 60;
}
} else {
r.x = nx;
r.y = ny;
}
}
// Update & draw splashes
ctx.fillStyle = 'rgba(205, 228, 250, 0.65)';
for (let i = splashes.length - 1; i >= 0; i--) {
const sp = splashes[i];
sp.vy += 480 * dt;
sp.x += sp.vx * dt;
sp.y += sp.vy * dt;
sp.life -= dt * 3.8;
if (sp.life <= 0 || sp.y > groundY + 2) {
splashes.splice(i, 1);
continue;
}
ctx.globalAlpha = sp.life * 0.7;
ctx.beginPath();
ctx.arc(sp.x, sp.y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// --- Wind Streaks (Visualizes fast gusts) ---
const windWisps = Array.from({ length: 14 }, () => ({
x: Math.random() * W,
y: Math.random() * groundY * 0.9,
len: 60 + Math.random() * 140,
speedMul: 0.8 + Math.random() * 0.5
}));
function drawEnvironment(dt) {
// Stormy sky gradient darkening with wind speed
const stormFactor = Math.min(1, actualWindKmh / 110);
const topR = Math.round(18 - stormFactor * 8);
const topG = Math.round(27 - stormFactor * 11);
const topB = Math.round(38 - stormFactor * 12);
const botR = Math.round(38 - stormFactor * 14);
const botG = Math.round(52 - stormFactor * 18);
const botB = Math.round(68 - stormFactor * 20);
const skyGrad = ctx.createLinearGradient(0, 0, 0, groundY);
skyGrad.addColorStop(0, `rgb(${topR},${topG},${topB})`);
skyGrad.addColorStop(1, `rgb(${botR},${botG},${botB})`);
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, W, groundY);
// Distant bending trees / streetlights on horizon for scale and wind cue
const bend = Math.pow(actualWindKmh / 95, 1.5) * 42;
ctx.save();
ctx.strokeStyle = 'rgba(14, 21, 30, 0.55)';
ctx.fillStyle = 'rgba(14, 21, 30, 0.55)';
const treePositions = [W * 0.12, W * 0.68, W * 0.84, W * 0.91];
for (let idx = 0; idx < treePositions.length; idx++) {
const tx = treePositions[idx];
const th = (65 + (idx % 3) * 22) * SCALE;
const flutter = Math.sin(gustTime * 5 + idx) * (actualWindKmh * 0.08);
const tipX = tx + bend + flutter;
const tipY = groundY - th + Math.abs(bend) * 0.22;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(tx, groundY);
ctx.quadraticCurveTo(tx + bend * 0.25, groundY - th * 0.55, tipX, tipY);
ctx.stroke();
// Foliage silhouette stretched downwind
ctx.beginPath();
ctx.ellipse(
tipX + bend * 0.22,
tipY + 8,
18 + Math.abs(bend) * 0.35,
26 - Math.min(10, Math.abs(bend) * 0.15),
Math.atan2(bend * 0.4, th) + 0.2,
0, Math.PI * 2
);
ctx.fill();
}
// Streetlamp with warm glow cone
const lampX = W * 0.58;
const lampH = 175 * SCALE;
const lampSway = Math.sin(gustTime * 4.2) * (actualWindKmh * 0.03);
ctx.strokeStyle = '#141e29';
ctx.lineWidth = 4.5;
ctx.beginPath();
ctx.moveTo(lampX, groundY);
ctx.quadraticCurveTo(lampX + lampSway * 0.4, groundY - lampH * 0.6, lampX - 18 + lampSway, groundY - lampH);
ctx.stroke();
const bulbX = lampX - 20 + lampSway;
const bulbY = groundY - lampH + 2;
const glow = ctx.createRadialGradient(bulbX, bulbY, 2, bulbX + 20, bulbY + 90, 210);
glow.addColorStop(0, 'rgba(255, 224, 165, 0.42)');
glow.addColorStop(0.35, 'rgba(255, 210, 140, 0.10)');
glow.addColorStop(1, 'rgba(255, 210, 140, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.moveTo(bulbX - 10, bulbY);
ctx.lineTo(bulbX - 130, groundY);
ctx.lineTo(bulbX + 210, groundY);
ctx.lineTo(bulbX + 10, bulbY);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffe9b8';
ctx.beginPath();
ctx.arc(bulbX, bulbY, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Subtle wind wisps when wind > 25 km/h
if (actualWindKmh > 20) {
const alpha = Math.min(0.16, (actualWindKmh - 20) / 550);
ctx.strokeStyle = `rgba(210, 230, 250, ${alpha})`;
ctx.lineWidth = 1.5;
const wSpeed = (actualWindKmh / 3.6) * 42;
for (const w of windWisps) {
w.x += wSpeed * w.speedMul * dt;
if (w.x - w.len > W) {
w.x = -50;
w.y = Math.random() * groundY * 0.88;
}
ctx.beginPath();
ctx.moveTo(w.x - w.len, w.y);
ctx.quadraticCurveTo(w.x - w.len * 0.5, w.y - 4, w.x, w.y + 2);
ctx.stroke();
}
}
// Wet asphalt ground
const grd = ctx.createLinearGradient(0, groundY, 0, H);
grd.addColorStop(0, '#121a24');
grd.addColorStop(0.15, '#0d141c');
grd.addColorStop(1, '#070b10');
ctx.fillStyle = grd;
ctx.fillRect(0, groundY, W, H - groundY);
// Wet reflection sheen under streetlamp and character
const sheen = ctx.createRadialGradient(lampX, groundY + 18, 5, lampX, groundY + 25, 190);
sheen.addColorStop(0, 'rgba(255, 215, 150, 0.14)');
sheen.addColorStop(1, 'rgba(255, 215, 150, 0)');
ctx.fillStyle = sheen;
ctx.fillRect(lampX - 200, groundY, 400, 60);
ctx.strokeStyle = 'rgba(150, 185, 220, 0.18)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, groundY);
ctx.lineTo(W, groundY);
ctx.stroke();
}
// Two-bone inverse kinematics helper
function solveIK(ax, ay, bx, by, L1, L2, bendSign) {
const dx = bx - ax;
const dy = by - ay;
const d = Math.min(L1 + L2 - 0.5, Math.max(Math.abs(L1 - L2) + 0.5, Math.hypot(dx, dy)));
const cosA = (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d);
const angleBase = Math.atan2(dy, dx);
const offset = Math.acos(Math.max(-1, Math.min(1, cosA))) * bendSign;
return {
jx: ax + Math.cos(angleBase + offset) * L1,
jy: ay + Math.sin(angleBase + offset) * L1
};
}
function drawPerson() {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const thighL = 41 * SCALE;
const calfL = 41 * SCALE;
// Person faces LEFT (-X), so knees bend LEFT (-1 bendSign from hip->foot)
const backKnee = solveIK(person.hipX, person.hipY, person.backFootX, groundY - 3, thighL, calfL, 1);
const frontKnee = solveIK(person.hipX, person.hipY, person.frontFootX, groundY - 3, thighL, calfL, 1);
// Subtle puddle shadow under feet
ctx.fillStyle = 'rgba(0, 0, 0, 0.42)';
ctx.beginPath();
ctx.ellipse(person.anchorX, groundY + 3, 45 * SCALE, 6 * SCALE, 0, 0, Math.PI * 2);
ctx.fill();
// Back leg
ctx.strokeStyle = '#18222f';
ctx.lineWidth = 12 * SCALE;
ctx.beginPath();
ctx.moveTo(person.hipX, person.hipY);
ctx.lineTo(backKnee.jx, backKnee.jy);
ctx.lineTo(person.backFootX, groundY - 4);
ctx.stroke();
// Back boot
ctx.fillStyle = '#10161f';
ctx.beginPath();
ctx.ellipse(person.backFootX - 6 * SCALE, groundY - 3, 12 * SCALE, 5 * SCALE, 0, Math.PI, Math.PI * 2);
ctx.fill();
// Back arm (helping grip slightly lower on handle when attached)
const upperArm = 27 * SCALE;
const foreArm = 26 * SCALE;
const secHandX = umb.attached ? person.handX - Math.sin(umb.angle) * 8 * SCALE : person.handX + 6 * SCALE;
const secHandY = umb.attached ? person.handY + Math.cos(umb.angle) * 8 * SCALE : person.handY + 6 * SCALE;
const backElbow = solveIK(person.shoulderX, person.shoulderY, secHandX, secHandY, upperArm, foreArm, -1);
ctx.strokeStyle = '#9e6b23';
ctx.lineWidth = 9.5 * SCALE;
ctx.beginPath();
ctx.moveTo(person.shoulderX, person.shoulderY);
ctx.lineTo(backElbow.jx, backElbow.jy);
ctx.lineTo(secHandX, secHandY);
ctx.stroke();
// Front leg
ctx.strokeStyle = '#223042';
ctx.lineWidth = 12.5 * SCALE;
ctx.beginPath();
ctx.moveTo(person.hipX, person.hipY);
ctx.lineTo(frontKnee.jx, frontKnee.jy);
ctx.lineTo(person.frontFootX, groundY - 4);
ctx.stroke();
// Front boot planted into wind
ctx.fillStyle = '#141b24';
ctx.beginPath();
ctx.moveTo(person.frontFootX + 7 * SCALE, groundY);
ctx.lineTo(person.frontFootX - 17 * SCALE, groundY);
ctx.quadraticCurveTo(person.frontFootX - 18 * SCALE, groundY - 9 * SCALE, person.frontFootX + 5 * SCALE, groundY - 7 * SCALE);
ctx.closePath();
ctx.fill();
// Raincoat body + whipping coat-tail
ctx.fillStyle = '#d4942a'; // classic yellow-ochre slicker
ctx.strokeStyle = '#b57b1c';
ctx.lineWidth = 2;
const cn = person.coatNodes;
ctx.beginPath();
ctx.moveTo(person.shoulderX - 11 * SCALE, person.shoulderY - 3 * SCALE);
ctx.lineTo(person.shoulderX + 11 * SCALE, person.shoulderY + 1 * SCALE);
// Down back to flapping coat nodes
for (let i = 1; i < cn.length; i++) {
ctx.lineTo(cn[i].x, cn[i].y);
}
// Coat hem across to front thigh
ctx.quadraticCurveTo(
person.hipX - 8 * SCALE,
person.hipY + 22 * SCALE,
person.hipX - 17 * SCALE,
person.hipY + 14 * SCALE
);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Head / Raincoat Hood tucked down against wind
const headTilt = person.leanAngle * 0.6 - Math.min(0.35, actualWindKmh * 0.003);
ctx.save();
ctx.translate(person.headX, person.headY);
ctx.rotate(headTilt);
// Face profile
ctx.fillStyle = '#e0b899';
ctx.beginPath();
ctx.arc(-3 * SCALE, 0, 9.5 * SCALE, 0, Math.PI * 2);
ctx.fill();
// Hood wrapping head
ctx.fillStyle = '#d4942a';
ctx.beginPath();
ctx.arc(1 * SCALE, 0, 12 * SCALE, -Math.PI * 0.72, Math.PI * 0.68);
ctx.quadraticCurveTo(-8 * SCALE, 15 * SCALE, -10 * SCALE, 9 * SCALE);
ctx.quadraticCurveTo(-3 * SCALE, 3 * SCALE, -6 * SCALE, -9 * SCALE);
ctx.closePath();
ctx.fill();
ctx.restore();
// Front arm (drawn after umbrella shaft in main loop, or right here? Let's draw umbrella shaft, then front arm over handle!)
ctx.restore();
}
function drawFrontArm() {
const upperArm = 27 * SCALE;
const foreArm = 26 * SCALE;
const frontElbow = solveIK(person.shoulderX, person.shoulderY, person.handX, person.handY, upperArm, foreArm, -1);
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#df9e30';
ctx.lineWidth = 10.5 * SCALE;
ctx.beginPath();
ctx.moveTo(person.shoulderX + 1 * SCALE, person.shoulderY + 2 * SCALE);
ctx.lineTo(frontElbow.jx, frontElbow.jy);
ctx.lineTo(person.handX, person.handY);
ctx.stroke();
// Hand / knuckles (reddening slightly as grip fatigue rises!)
const strain = Math.min(1, person.gripFatigue);
const r = Math.round(224 + strain * 25);
const g = Math.round(184 - strain * 50);
const b = Math.round(153 - strain * 45);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(person.handX, person.handY, 4.8 * SCALE, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawUmbrella() {
if (umb.x > W + 250 || umb.y < -300) return;
const sinA = Math.sin(umb.angle);
const cosA = Math.cos(umb.angle);
const sx = sinA;
const sy = -cosA;
const nx = cosA;
const ny = sinA;
const notchX = umb.x + sx * umb.shaftLen;
const notchY = umb.y + sy * umb.shaftLen;
const ferruleX = notchX + sx * (10 * SCALE);
const ferruleY = notchY + sy * (10 * SCALE);
const runnerX = umb.x + sx * (umb.shaftLen * umb.runnerPos);
const runnerY = umb.y + sy * (umb.shaftLen * umb.runnerPos);
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 1. Central Metal Shaft & J-Hook Handle
ctx.strokeStyle = '#9eb1c4';
ctx.lineWidth = 2.8 * SCALE;
ctx.beginPath();
ctx.moveTo(umb.x - sx * 6 * SCALE, umb.y - sy * 6 * SCALE);
ctx.lineTo(ferruleX, ferruleY);
ctx.stroke();
// Wooden curved J-handle
const hBaseX = umb.x - sx * 6 * SCALE;
const hBaseY = umb.y - sy * 6 * SCALE;
ctx.strokeStyle = '#6b3e26';
ctx.lineWidth = 4.5 * SCALE;
ctx.beginPath();
ctx.moveTo(umb.x + sx * 4 * SCALE, umb.y + sy * 4 * SCALE);
ctx.lineTo(hBaseX, hBaseY);
const hookCenterX = hBaseX - nx * 7 * SCALE;
const hookCenterY = hBaseY - ny * 7 * SCALE;
const startAng = Math.atan2(hBaseY - hookCenterY, hBaseX - hookCenterX);
ctx.arc(hookCenterX, hookCenterY, 7 * SCALE, startAng, startAng + Math.PI, false);
ctx.stroke();
// 2. Stretcher struts & Runner collar (underneath fabric when normal, exposed when inverted)
const leftRib = umb.ribs[0].pts;
const rightRib = umb.ribs[1].pts;
function drawFrameStruts() {
ctx.strokeStyle = '#8da2b8';
ctx.lineWidth = 1.9 * SCALE;
ctx.beginPath();
ctx.moveTo(runnerX, runnerY);
ctx.lineTo(leftRib[2].x, leftRib[2].y);
ctx.moveTo(runnerX, runnerY);
ctx.lineTo(rightRib[2].x, rightRib[2].y);
// Center foreshortened strut
const midJ2X = (leftRib[2].x + rightRib[2].x) * 0.5 + sx * 3 * SCALE;
const midJ2Y = (leftRib[2].y + rightRib[2].y) * 0.5 + sy * 3 * SCALE;
ctx.moveTo(runnerX, runnerY);
ctx.lineTo(midJ2X, midJ2Y);
ctx.stroke();
// Runner ring
ctx.fillStyle = '#cfdbe8';
ctx.beginPath();
ctx.arc(runnerX, runnerY, 3.2 * SCALE, 0, Math.PI * 2);
ctx.fill();
}
if (!umb.inverted) drawFrameStruts();
// Compute a synthesized central rib halfway between left & right ribs (offset slightly toward viewer/dome)
// to give the canopy rich 3D scalloped gores!
const midRib = [];
const windFlutter = Math.sin(gustTime * 24) * Math.min(6, actualWindKmh * 0.06);
for (let i = 0; i <= RIB_SEGS; i++) {
const t = i / RIB_SEGS;
// Bulge slightly along shaft axis depending on dome orientation
const domeSign = umb.inverted ? 1 : -1;
const bulge = Math.sin(t * Math.PI) * 9 * SCALE * domeSign;
midRib.push({
x: (leftRib[i].x + rightRib[i].x) * 0.5 + sx * bulge + nx * windFlutter * t * 0.3,
y: (leftRib[i].y + rightRib[i].y) * 0.5 + sy * bulge + ny * windFlutter * t * 0.3
});
}
// Back/interior dark lining of canopy (visible when tilted or inverted)
ctx.fillStyle = '#5c1922';
ctx.beginPath();
ctx.moveTo(leftRib[0].x, leftRib[0].y);
for (let i = 1; i <= RIB_SEGS; i++) ctx.lineTo(leftRib[i].x, leftRib[i].y);
ctx.quadraticCurveTo(
(leftRib[RIB_SEGS].x + rightRib[RIB_SEGS].x) * 0.5 - sx * 6 * SCALE,
(leftRib[RIB_SEGS].y + rightRib[RIB_SEGS].y) * 0.5 - sy * 6 * SCALE,
rightRib[RIB_SEGS].x, rightRib[RIB_SEGS].y
);
for (let i = RIB_SEGS - 1; i >= 0; i--) ctx.lineTo(rightRib[i].x, rightRib[i].y);
ctx.closePath();
ctx.fill();
// Left Gore Panel (Crimson red)
ctx.fillStyle = umb.inverted ? '#9e2835' : '#c93444';
ctx.beginPath();
ctx.moveTo(leftRib[0].x, leftRib[0].y);
for (let i = 1; i <= RIB_SEGS; i++) ctx.lineTo(leftRib[i].x, leftRib[i].y);
// Scalloped hem between left tip and mid tip
const scallopSign = umb.inverted ? -1 : 1;
const lcX = (leftRib[RIB_SEGS].x + midRib[RIB_SEGS].x) * 0.5 + sx * (8 * SCALE * scallopSign);
const lcY = (leftRib[RIB_SEGS].y + midRib[RIB_SEGS].y) * 0.5 + sy * (8 * SCALE * scallopSign + windFlutter * 0.4);
ctx.quadraticCurveTo(lcX, lcY, midRib[RIB_SEGS].x, midRib[RIB_SEGS].y);
for (let i = RIB_SEGS - 1; i >= 0; i--) ctx.lineTo(midRib[i].x, midRib[i].y);
ctx.closePath();
ctx.fill();
// Right Gore Panel (Slightly shaded crimson for 3D form)
ctx.fillStyle = umb.inverted ? '#b8303f' : '#ab2736';
ctx.beginPath();
ctx.moveTo(midRib[0].x, midRib[0].y);
for (let i = 1; i <= RIB_SEGS; i++) ctx.lineTo(midRib[i].x, midRib[i].y);
const rcX = (midRib[RIB_SEGS].x + rightRib[RIB_SEGS].x) * 0.5 + sx * (8 * SCALE * scallopSign);
const rcY = (midRib[RIB_SEGS].y + rightRib[RIB_SEGS].y) * 0.5 + sy * (8 * SCALE * scallopSign - windFlutter * 0.4);
ctx.quadraticCurveTo(rcX, rcY, rightRib[RIB_SEGS].x, rightRib[RIB_SEGS].y);
for (let i = RIB_SEGS - 1; i >= 0; i--) ctx.lineTo(rightRib[i].x, rightRib[i].y);
ctx.closePath();
ctx.fill();
// Fiberglass Rib Seam Lines & Metallic Rib Tips
ctx.strokeStyle = 'rgba(40, 10, 15, 0.55)';
ctx.lineWidth = 1.8 * SCALE;
for (const rPts of [leftRib, midRib, rightRib]) {
ctx.beginPath();
ctx.moveTo(rPts[0].x, rPts[0].y);
for (let i = 1; i <= RIB_SEGS; i++) ctx.lineTo(rPts[i].x, rPts[i].y);
ctx.stroke();
// Silver bead at tip
ctx.fillStyle = '#dce7f2';
ctx.beginPath();
ctx.arc(rPts[RIB_SEGS].x, rPts[RIB_SEGS].y, 2.0 * SCALE, 0, Math.PI * 2);
ctx.fill();
}
// When inverted, stretcher struts and central shaft are visibly exposed inside the upward-cupped cone!
if (umb.inverted) {
ctx.strokeStyle = '#9eb1c4';
ctx.lineWidth = 2.4 * SCALE;
ctx.beginPath();
ctx.moveTo(runnerX, runnerY);
ctx.lineTo(ferruleX, ferruleY);
ctx.stroke();
drawFrameStruts();
}
ctx.restore();
}
function updateHUD() {
windNumEl.textContent = Math.round(actualWindKmh);
beaufortEl.textContent = getBeaufort(actualWindKmh);
if (!umb.attached) {
statusPill.textContent = 'LOST TO GALE';
statusPill.style.background = '#591f27';
statusPill.style.color = '#ff9aa8';
} else if (umb.inverted) {
statusPill.textContent = 'INVERTED!';
statusPill.style.background = '#5c3b14';
statusPill.style.color = '#ffd089';
} else if (person.gripFatigue > 0.35) {
statusPill.textContent = 'GRIP SLIPPING';
statusPill.style.background = '#4f2c3a';
statusPill.style.color = '#ffb3c6';
} else if (actualWindKmh > 42) {
statusPill.textContent = 'BRACING';
statusPill.style.background = '#233f5e';
statusPill.style.color = '#9ecaff';
} else {
statusPill.textContent = 'HOLDING';
statusPill.style.background = '#1f3347';
statusPill.style.color = '#90b8e0';
}
}
// --- Initialize & Run ---
resetUmbrella();
let lastTime = performance.now();
function frame(now) {
const dt = Math.min(0.033, (now - lastTime) / 1000);
lastTime = now;
gustTime += dt;
// Keyboard wind control
if (keys['ArrowRight'] || keys['ArrowUp']) {
setAutoRise(false);
baseWindKmh = Math.min(135, baseWindKmh + 34 * dt);
windSlider.value = baseWindKmh;
}
if (keys['ArrowLeft'] || keys['ArrowDown']) {
setAutoRise(false);
baseWindKmh = Math.max(0, baseWindKmh - 34 * dt);
windSlider.value = baseWindKmh;
}
// Auto-rise wind ramp
if (autoRise) {
baseWindKmh = Math.min(125, baseWindKmh + 2.8 * dt);
windSlider.value = baseWindKmh;
}
// Natural multi-frequency gusting around baseWindKmh
const gustWave =
Math.sin(gustTime * 1.35) * 0.45 +
Math.sin(gustTime * 3.1 + 1.2) * 0.35 +
Math.sin(gustTime * 6.7 + 0.4) * 0.20;
const gustAmp = Math.min(18, baseWindKmh * 0.22);
const targetActual = Math.max(0, baseWindKmh + gustWave * gustAmp);
actualWindKmh += (targetActual - actualWindKmh) * Math.min(1, dt * 6);
// Step simulation
updatePersonKinematics(dt);
stepUmbrellaPhysics(dt);
// Render scene
drawEnvironment(dt);
drawPerson();
drawUmbrella();
if (umb.attached) {
drawFrontArm();
} else {
drawFrontArm();
}
updateAndDrawRain(dt);
updateHUD();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
It captures the core mechanical drama well: leaning the umbrella into a rising breeze loads the canopy asymmetrically, gusts make the wrist fight to keep the dome pointed windward, and around Force 7–8 the stretchers drive the runner down the shaft and snap the ribs over-center into an upward-cupped cone—which you can actually pop back into shape if you drag the wind slider down or let it catch from behind before the person's grip gives out around storm force. Where it cheats and looks wrong compared to a real umbrella in a gale is dimensionality and torsional chaos: a real 8-rib umbrella almost never inverts symmetrically all at once—one or two windward panels fold back first while circumferential fabric tension twists the canopy like a corkscrew, permanently kinking cheap stamped-tin stretchers so one rib dangles limp afterward. Here, because the physics runs on a 2D cross-sectional linkage with a synthesized central rib, both sides tend to snap over-center nearly together without helical twist, the fiberglass ribs never suffer permanent plastic crease damage, and the rain sheet doesn't shed off the inverted cone in a concentrated downwind rooster-tail of spray.