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 umbrella canopy is modeled in 2D cross-section as two articulated spring-loaded rib wings (each with a pivoting root segment and a flexible outer tip segment) attached near the shaft crown, linked to a four-panel fabric profile on which per-panel aerodynamic pressure (concave scoop drag vs. convex deflection) and curved-dome suction lift are integrated. Inversion is a discrete state change on a bistable rib potential representing the over-center stretcher-and-rib linkage: normally each rib wing rests at a convex dome angle (~66° from the downward shaft), softening as wind lift flexes the tip and forces the rib root past a critical dead-center angle (~99°), at which point the wing snaps across into a flipped inverted equilibrium (~140°) with hysteresis so it remains inside-out even if the wind eases. Whether and when inversion happens depends on the instantaneous wind speed plus gusts, the aerodynamic lift and under-rim pressure caught by the canopy, and how successfully the person's wrist torque can keep the shaft tilted into the wind before their grip limit is overwhelmed.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Umbrella in a Gale</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0c131d;
color: #e7eef7;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
-webkit-user-select: none;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.hud-top {
position: fixed;
top: 16px;
left: 16px;
right: 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
gap: 12px;
flex-wrap: wrap;
}
.card {
pointer-events: auto;
background: rgba(11, 18, 28, 0.72);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(173, 204, 238, 0.16);
border-radius: 12px;
padding: 12px 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.wind-readout {
display: flex;
align-items: baseline;
gap: 10px;
}
.wind-val {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1;
color: #f4f8ff;
}
.wind-unit {
font-size: 0.9rem;
color: #9eb4cc;
font-weight: 500;
}
.wind-sub {
margin-top: 5px;
font-size: 0.82rem;
color: #a9bfd6;
display: flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.02em;
background: rgba(130, 175, 220, 0.14);
border: 1px solid rgba(130, 175, 220, 0.25);
color: #cde1f7;
}
.badge.warn {
background: rgba(245, 175, 65, 0.18);
border-color: rgba(245, 175, 65, 0.42);
color: #ffd382;
}
.badge.danger {
background: rgba(235, 75, 75, 0.2);
border-color: rgba(235, 75, 75, 0.48);
color: #ff9a9a;
}
.status-stack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.hud-bottom {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
width: min(760px, calc(100% - 24px));
pointer-events: auto;
}
.controls-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.slider-wrap {
flex: 1 1 220px;
display: flex;
align-items: center;
gap: 10px;
min-width: 190px;
}
.slider-wrap label {
font-size: 0.8rem;
font-weight: 600;
color: #b8cbe0;
white-space: nowrap;
}
input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #3c6997 0%, #d4943a 60%, #cf4747 100%);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #f5f9ff;
border: 2px solid #162231;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
cursor: grab;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #f5f9ff;
border: 2px solid #162231;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
cursor: grab;
}
.btn {
appearance: none;
border: 1px solid rgba(173, 204, 238, 0.22);
background: rgba(31, 47, 68, 0.7);
color: #e7f0fa;
font-size: 0.8rem;
font-weight: 600;
padding: 7px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease;
white-space: nowrap;
}
.btn:hover {
background: rgba(48, 72, 102, 0.85);
border-color: rgba(173, 204, 238, 0.42);
}
.btn:active {
transform: translateY(1px);
}
.btn.active {
background: rgba(65, 132, 204, 0.32);
border-color: rgba(114, 182, 255, 0.6);
color: #ffffff;
}
.hint {
margin-top: 8px;
font-size: 0.74rem;
color: #839ab3;
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.meter-bar {
width: 120px;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.meter-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: #59b4ff;
transition: width 0.08s linear, background-color 0.15s ease;
}
</style>
</head>
<body>
<canvas id="sim"></canvas>
<div class="hud-top">
<div class="card">
<div class="wind-readout">
<span class="wind-val" id="windMps">2.0</span>
<span class="wind-unit">m/s</span>
<span class="badge" id="beaufortBadge">Light Breeze</span>
</div>
<div class="wind-sub">
<span id="windMph">4 mph</span>
<span>·</span>
<span id="windKmh">7 km/h</span>
</div>
</div>
<div class="card status-stack">
<span class="badge" id="umbStatusBadge">Umbrella: Intact</span>
<div style="display:flex;align-items:center;gap:8px;font-size:0.75rem;color:#9eb4cc;">
<span>Grip load</span>
<div class="meter-bar"><div class="meter-fill" id="gripMeter"></div></div>
</div>
</div>
</div>
<div class="hud-bottom card">
<div class="controls-row">
<div class="slider-wrap">
<label for="windSlider">Wind</label>
<input id="windSlider" type="range" min="0" max="40" step="0.1" value="2" />
</div>
<button class="btn active" id="autoBtn" type="button">Auto-rise: On</button>
<button class="btn" id="resetUmbBtn" type="button">New Umbrella</button>
<button class="btn" id="soundBtn" type="button">Sound: Off</button>
</div>
<div class="hint">
<span>Keys: <strong>← / →</strong> or <strong>↓ / ↑</strong> drive wind · <strong>Space</strong> pause rise · <strong>R</strong> new umbrella</span>
<span id="gustNote">Gusts active</span>
</div>
</div>
<script>
(() => {
"use strict";
const canvas = document.getElementById("sim");
const ctx = canvas.getContext("2d");
const windMpsEl = document.getElementById("windMps");
const windMphEl = document.getElementById("windMph");
const windKmhEl = document.getElementById("windKmh");
const beaufortBadgeEl = document.getElementById("beaufortBadge");
const umbStatusBadgeEl = document.getElementById("umbStatusBadge");
const gripMeterEl = document.getElementById("gripMeter");
const windSlider = document.getElementById("windSlider");
const autoBtn = document.getElementById("autoBtn");
const resetUmbBtn = document.getElementById("resetUmbBtn");
const soundBtn = document.getElementById("soundBtn");
const gustNoteEl = document.getElementById("gustNote");
// ---------- Viewport / World Mapping ----------
let width = 1280;
let height = 720;
let dpr = 1;
let PPM = 140;
let originX = 400;
let originY = 560;
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
PPM = clamp(Math.min(width / 7.2, height / 3.9), 72, 175);
originX = width * 0.34;
originY = height * 0.80;
}
window.addEventListener("resize", resize);
resize();
// ---------- Math Helpers ----------
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function solve2BoneIK(ax, ay, tx, ty, l1, l2, bendSign) {
let dx = tx - ax;
let dy = ty - ay;
let dist = Math.hypot(dx, dy);
const minDist = Math.abs(l1 - l2) + 0.01;
const maxDist = l1 + l2 - 0.004;
if (dist < 1e-6) {
dx = 0;
dy = -1;
dist = minDist;
}
const clamped = clamp(dist, minDist, maxDist);
const ux = dx / dist;
const uy = dy / dist;
const ex = ax + ux * clamped;
const ey = ay + uy * clamped;
const cosA = clamp((l1 * l1 + clamped * clamped - l2 * l2) / (2 * l1 * clamped), -1, 1);
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA)) * bendSign;
const px = -uy;
const py = ux;
const jx = ax + (ux * cosA + px * sinA) * l1;
const jy = ay + (uy * cosA + py * sinA) * l1;
return { jx, jy, ex, ey };
}
function worldToScreen(wx, wy) {
return [originX + wx * PPM, originY - wy * PPM];
}
// ---------- Procedural Web Audio (Off by default) ----------
const audio = {
enabled: false,
ctx: null,
windGain: null,
windFilter: null,
rainGain: null,
noiseBuffer: null
};
function initAudio() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return false;
const actx = new AudioCtx();
const len = actx.sampleRate * 3;
const buf = actx.createBuffer(1, len, actx.sampleRate);
const data = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < len; i++) {
const white = Math.random() * 2 - 1;
last = (last + 0.04 * white) / 1.04;
data[i] = white * 0.45 + last * 1.4;
}
const windSrc = actx.createBufferSource();
windSrc.buffer = buf;
windSrc.loop = true;
const windFilter = actx.createBiquadFilter();
windFilter.type = "bandpass";
windFilter.frequency.value = 220;
windFilter.Q.value = 2.8;
const windGain = actx.createGain();
windGain.gain.value = 0.0;
windSrc.connect(windFilter).connect(windGain).connect(actx.destination);
windSrc.start();
const rainSrc = actx.createBufferSource();
rainSrc.buffer = buf;
rainSrc.loop = true;
const rainFilter = actx.createBiquadFilter();
rainFilter.type = "highpass";
rainFilter.frequency.value = 1600;
const rainGain = actx.createGain();
rainGain.gain.value = 0.04;
rainSrc.connect(rainFilter).connect(rainGain).connect(actx.destination);
rainSrc.start();
audio.ctx = actx;
audio.windFilter = windFilter;
audio.windGain = windGain;
audio.rainGain = rainGain;
audio.noiseBuffer = buf;
return true;
}
function playSnapSound(intensity = 1) {
if (!audio.enabled || !audio.ctx) return;
const actx = audio.ctx;
const now = actx.currentTime;
const src = actx.createBufferSource();
src.buffer = audio.noiseBuffer;
const filt = actx.createBiquadFilter();
filt.type = "bandpass";
filt.frequency.setValueAtTime(950, now);
filt.frequency.exponentialRampToValueAtTime(240, now + 0.16);
filt.Q.value = 1.4;
const g = actx.createGain();
g.gain.setValueAtTime(0.28 * intensity, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.19);
src.connect(filt).connect(g).connect(actx.destination);
src.start(now);
src.stop(now + 0.2);
}
function toggleSound() {
if (!audio.ctx && !initAudio()) return;
if (audio.ctx.state === "suspended") {
audio.ctx.resume();
}
audio.enabled = !audio.enabled;
soundBtn.textContent = audio.enabled ? "Sound: On" : "Sound: Off";
soundBtn.classList.toggle("active", audio.enabled);
if (!audio.enabled && audio.windGain && audio.rainGain) {
audio.windGain.gain.setTargetAtTime(0, audio.ctx.currentTime, 0.05);
audio.rainGain.gain.setTargetAtTime(0, audio.ctx.currentTime, 0.05);
}
}
// ---------- Simulation Constants & State ----------
const GRAVITY = 9.81;
const AIR_RHO = 1.225;
const wind = {
base: 2.0,
current: 2.0,
gustFactor: 0,
autoRise: true,
sliderDragging: false,
time: 0,
cameraShake: 0
};
const keys = {
left: false,
right: false,
up: false,
down: false
};
function createRib(side) {
return {
side,
alpha: 1.15, // rad from downward shaft (-u)
alphaVel: 0,
bend: 0, // outer tip flex (rad)
bendVel: 0,
inverted: false,
invertFlash: 0
};
}
const person = {
anchorX: -0.75,
hipX: -0.75,
hipY: 0.84,
hipVx: 0,
lean: -0.02, // 0 = upright, negative = leaning left (into wind)
leanVel: 0,
crouch: 0,
footL: { x: -0.90, y: 0, startX: -0.90, targetX: -0.90, lift: 0, progress: 1 },
footR: { x: -0.60, y: 0, startX: -0.60, targetX: -0.60, lift: 0, progress: 1 },
stepCooldown: 0,
handL: { x: -0.42, y: 1.12 },
handR: { x: -0.42, y: 1.12 },
handLVel: { x: 0, y: 0 },
handRVel: { x: 0, y: 0 },
recoilTimer: 0,
coatNodes: [],
scarfNodes: []
};
function initChains() {
person.coatNodes = [];
person.scarfNodes = [];
for (let i = 0; i < 6; i++) {
person.coatNodes.push({ x: person.hipX, y: person.hipY - i * 0.075, ox: person.hipX, oy: person.hipY - i * 0.075 });
person.scarfNodes.push({ x: person.hipX, y: 1.38 - i * 0.06, ox: person.hipX, oy: 1.38 - i * 0.06 });
}
}
initChains();
const umbrella = {
attached: true,
state: "normal", // "normal", "straining", "inverted", "lost"
hx: -0.42,
hy: 1.12,
vx: 0,
vy: 0,
theta: -0.08, // shaft angle: 0 up, negative tilted left into wind
omega: 0,
shaftLen: 0.88,
runnerOffset: 0.25,
ribLen: 0.56,
mass: 0.52,
inertia: 0.11,
ribs: [createRib(-1), createRib(1)],
gripRatio: 0,
overloadTime: 0,
lastGripForce: { x: 0, y: 0 },
lastAerForce: { x: 0, y: 0 },
flutterPhase: 0,
lostTimer: 0
};
function resetUmbrella() {
const shX = person.hipX + 0.52 * Math.sin(person.lean);
const shY = person.hipY + 0.52 * Math.cos(person.lean);
umbrella.attached = true;
umbrella.state = "normal";
umbrella.hx = shX + 0.30;
umbrella.hy = shY - 0.14;
umbrella.vx = 0;
umbrella.vy = 0;
umbrella.theta = clamp(-0.04 - wind.current * 0.018, -0.58, 0.1);
umbrella.omega = 0;
umbrella.ribs[0] = createRib(-1);
umbrella.ribs[1] = createRib(1);
umbrella.gripRatio = 0;
umbrella.overloadTime = 0;
umbrella.lostTimer = 0;
person.recoilTimer = 0;
person.handR.x = umbrella.hx;
person.handR.y = umbrella.hy;
person.handL.x = umbrella.hx;
person.handL.y = umbrella.hy;
}
// ---------- Rain, Splashes, Wind Streaks, Trees ----------
const raindrops = [];
const splashes = [];
const windStreaks = [];
const clouds = [];
const trees = [];
function initAtmosphere() {
for (let i = 0; i < 340; i++) {
raindrops.push({
x: (Math.random() - 0.2) * 14 - 3,
y: Math.random() * 5.2,
len: 0.12 + Math.random() * 0.18,
speedBias: 0.85 + Math.random() * 0.32,
alpha: 0.18 + Math.random() * 0.42
});
}
for (let i = 0; i < 60; i++) {
windStreaks.push({
x: Math.random() * 12 - 4,
y: 0.08 + Math.random() * 4.0,
len: 0.25 + Math.random() * 0.7,
alpha: 0.06 + Math.random() * 0.14,
speed: 0.8 + Math.random() * 0.45
});
}
for (let i = 0; i < 9; i++) {
clouds.push({
x: (i / 9) * 14 - 4,
y: 2.9 + Math.random() * 1.3,
w: 1.8 + Math.random() * 2.2,
h: 0.35 + Math.random() * 0.35,
speed: 0.06 + Math.random() * 0.07,
shade: 0.05 + Math.random() * 0.08
});
}
const treeXs = [-3.1, -1.9, 0.7, 2.3, 3.8, 5.2];
for (let i = 0; i < treeXs.length; i++) {
trees.push({
x: treeXs[i],
height: 1.7 + (i % 3) * 0.55,
sway: 0,
swayVel: 0,
seed: i * 1.73
});
}
}
initAtmosphere();
function spawnSplash(wx, wy, vx, vy, count = 1, bright = false) {
for (let i = 0; i < count; i++) {
if (splashes.length > 180) splashes.shift();
splashes.push({
x: wx,
y: wy,
vx: vx + (Math.random() - 0.5) * 1.4,
vy: vy + 0.5 + Math.random() * 1.4,
life: 0.22 + Math.random() * 0.2,
maxLife: 0.38,
bright
});
}
}
// ---------- Controls ----------
windSlider.addEventListener("input", () => {
wind.sliderDragging = true;
wind.base = parseFloat(windSlider.value);
});
windSlider.addEventListener("change", () => {
wind.sliderDragging = false;
});
window.addEventListener("pointerup", () => {
wind.sliderDragging = false;
});
autoBtn.addEventListener("click", () => {
wind.autoRise = !wind.autoRise;
autoBtn.textContent = wind.autoRise ? "Auto-rise: On" : "Auto-rise: Off";
autoBtn.classList.toggle("active", wind.autoRise);
});
resetUmbBtn.addEventListener("click", () => {
resetUmbrella();
});
soundBtn.addEventListener("click", () => {
toggleSound();
});
window.addEventListener("keydown", (e) => {
if (e.code === "ArrowLeft" || e.code === "ArrowDown") {
keys.left = true;
e.preventDefault();
} else if (e.code === "ArrowRight" || e.code === "ArrowUp") {
keys.right = true;
e.preventDefault();
} else if (e.code === "Space") {
e.preventDefault();
autoBtn.click();
} else if (e.code === "KeyR") {
resetUmbrella();
}
});
window.addEventListener("keyup", (e) => {
if (e.code === "ArrowLeft" || e.code === "ArrowDown") keys.left = false;
if (e.code === "ArrowRight" || e.code === "ArrowUp") keys.right = false;
});
// ---------- Umbrella Canopy Geometry & Aerodynamics ----------
function getUmbrellaPoints() {
const ux = Math.sin(umbrella.theta);
const uy = Math.cos(umbrella.theta);
const vx = Math.cos(umbrella.theta);
const vy = -Math.sin(umbrella.theta);
const crownX = umbrella.hx + ux * umbrella.shaftLen;
const crownY = umbrella.hy + uy * umbrella.shaftLen;
const runnerX = umbrella.hx + ux * (umbrella.shaftLen - umbrella.runnerOffset);
const runnerY = umbrella.hy + uy * (umbrella.shaftLen - umbrella.runnerOffset);
const wings = umbrella.ribs.map((rib) => {
const s = rib.side;
// Direction of inner rib from crown
const dirX = -ux * Math.cos(rib.alpha) + s * vx * Math.sin(rib.alpha);
const dirY = -uy * Math.cos(rib.alpha) + s * vy * Math.sin(rib.alpha);
// Outward bending normal (rotating dir toward inverted direction)
const nX = ux * Math.sin(rib.alpha) + s * vx * Math.cos(rib.alpha);
const nY = uy * Math.sin(rib.alpha) + s * vy * Math.cos(rib.alpha);
const midLen = umbrella.ribLen * 0.52;
const tipLen = umbrella.ribLen * 0.48;
const midX = crownX + dirX * midLen;
const midY = crownY + dirY * midLen;
const tipAngle = rib.bend;
const outerDirX = dirX * Math.cos(tipAngle) + nX * Math.sin(tipAngle);
const outerDirY = dirY * Math.cos(tipAngle) + nY * Math.sin(tipAngle);
const tipX = midX + outerDirX * tipLen;
const tipY = midY + outerDirY * tipLen;
return {
rib,
midX,
midY,
tipX,
tipY,
dirX,
dirY,
nX,
nY
};
});
return { ux, uy, vx, vy, crownX, crownY, runnerX, runnerY, wings };
}
function computeWindAt(x, y) {
// Slight boundary layer profile near pavement
const heightFactor = clamp(0.68 + 0.35 * Math.pow(Math.max(y, 0.05), 0.25), 0.65, 1.08);
const spatialWave = Math.sin(wind.time * 2.2 - x * 0.65) * 0.05 * wind.current;
return {
vx: Math.max(0, wind.current * heightFactor + spatialWave),
vy: Math.sin(wind.time * 3.1 - x * 0.9) * 0.045 * wind.current
};
}
function stepUmbrellaPhysics(dt) {
const geom = getUmbrellaPoints();
const { crownX, crownY, wings } = geom;
// Center of mass of umbrella roughly near upper shaft/canopy
const comX = umbrella.hx + geom.ux * 0.58;
const comY = umbrella.hy + geom.uy * 0.58;
let netFx = 0;
let netFy = -umbrella.mass * GRAVITY;
let netTorque = 0; // clockwise torque around handle or CoM; we accumulate around CoM then transfer
// Effective 3D frontal/dome area distributed across 4 panels
const panelArea = 0.24;
wings.forEach((w) => {
const rib = w.rib;
const s = rib.side;
const panels = [
{ ax: crownX, ay: crownY, bx: w.midX, by: w.midY, weight: 0.48 },
{ ax: w.midX, ay: w.midY, bx: w.tipX, by: w.tipY, weight: 0.52 }
];
let ribOpeningTorque = 0;
let tipBendTorque = 0;
panels.forEach((p, idx) => {
const mx = (p.ax + p.bx) * 0.5;
const my = (p.ay + p.by) * 0.5;
const tx = p.bx - p.ax;
const ty = p.by - p.ay;
const len = Math.hypot(tx, ty) || 0.01;
const utx = tx / len;
const uty = ty / len;
// Outward normal (pointing out of the top/convex side of normal dome)
// For left rib (s = -1): clockwise 90 deg from panel vector; right rib (s = +1): counter-clockwise 90 deg
const outNx = s < 0 ? uty : -uty;
const outNy = s < 0 ? -utx : utx;
// Panel velocity from umbrella linear + angular + rib motion
const rx = mx - comX;
const ry = my - comY;
const rCrownX = mx - crownX;
const rCrownY = my - crownY;
const pvx = umbrella.vx + umbrella.omega * ry + rib.alphaVel * s * (rCrownY * 0.35);
const pvy = umbrella.vy - umbrella.omega * rx - rib.alphaVel * s * (rCrownX * 0.35);
const wVel = computeWindAt(mx, my);
const relVx = wVel.vx - pvx;
const relVy = wVel.vy - pvy;
const relSpeed = Math.hypot(relVx, relVy);
const vn = relVx * outNx + relVy * outNy;
const vt = relVx * utx + relVy * uty;
// Aerodynamic pressure:
// vn > 0 means wind strikes the concave underside -> strong scoop drag!
// vn < 0 means wind strikes convex outer face -> sheds smoothly
const cd = vn > 0 ? 1.52 : 0.88;
const pressMag = 0.5 * AIR_RHO * cd * Math.abs(vn) * vn * panelArea * p.weight;
// Curved dome suction/lift: horizontal airflow rushing past the canopy creates upward/outward suction along +outN
// Much stronger when canopy is facing crosswise to the wind, weaker once inverted & shredded
const suctionCoef = rib.inverted ? 0.14 : 0.48;
const flutter = rib.inverted
? Math.sin(umbrella.flutterPhase * 1.9 + s * 1.4 + idx) * 0.25 * 0.5 * AIR_RHO * relSpeed * relSpeed * panelArea
: Math.sin(umbrella.flutterPhase + s * 0.8 + idx) * 0.045 * 0.5 * AIR_RHO * relSpeed * relSpeed * panelArea;
const liftMag = 0.5 * AIR_RHO * suctionCoef * vt * Math.abs(vt) * panelArea * p.weight * (idx === 0 ? 1.15 : 0.85);
// Make sure suction always pulls outward from dome (+outN)
const totalNormalForce = pressMag + Math.abs(liftMag) + flutter;
// Tangential skin drag
const tanForce = 0.5 * AIR_RHO * 0.08 * relSpeed * vt * panelArea * p.weight;
const fx = outNx * totalNormalForce + utx * tanForce;
const fy = outNy * totalNormalForce + uty * tanForce;
netFx += fx;
netFy += fy;
netTorque += ry * fx - rx * fy;
// Rib opening torque around crown hinge:
// Positive rib torque increases rib.alpha (peeling umbrella open toward inversion)
// Torque around crown from force (fx, fy) at (rCrownX, rCrownY):
// Clockwise = rCrownY * fx - rCrownX * fy. For right rib (s=+1), opening upward is counter-clockwise (-); for left rib (s=-1), opening upward is clockwise (+).
const crownTorque = rCrownY * fx - rCrownX * fy;
const openSign = -s;
ribOpeningTorque += crownTorque * openSign;
if (idx === 1) {
tipBendTorque += crownTorque * openSign * 0.65;
}
});
// Bistable rib + stretcher linkage mechanics
const alphaNormal = 1.14; // ~65 deg
const alphaSnap = 1.71; // ~98 deg (dead-center over-stretcher threshold)
const alphaInverted = 2.40; // ~137 deg
const alphaUnflip = 1.46; // ~84 deg hysteresis return threshold
let springTorque = 0;
if (!rib.inverted) {
const disp = rib.alpha - alphaNormal;
// Nonlinear stiffening then softening right before dead-center snap-through
const proximity = clamp((rib.alpha - alphaNormal) / (alphaSnap - alphaNormal), 0, 1.15);
const snapSoften = 1 - 0.52 * proximity * proximity;
springTorque = -46.0 * disp * snapSoften - 1.9 * rib.alphaVel;
if (rib.alpha > alphaSnap) {
rib.inverted = true;
rib.invertFlash = 1.0;
rib.alphaVel += 6.5;
rib.bendVel += 4.0;
playSnapSound(1.0);
spawnSplash(w.tipX, w.tipY, wind.current * 0.4, 0.8, 10, true);
wind.cameraShake = Math.max(wind.cameraShake, 0.025);
}
} else {
const disp = rib.alpha - alphaInverted;
springTorque = -34.0 * disp - 1.5 * rib.alphaVel;
// Hysteresis: only flips back if forced strongly below unflip threshold
if (rib.alpha < alphaUnflip) {
rib.inverted = false;
rib.alphaVel -= 5.0;
playSnapSound(0.75);
}
}
// Rib inertia ~0.045 kg*m^2
const ribInertia = 0.042;
const alphaAcc = (ribOpeningTorque + springTorque) / ribInertia;
rib.alphaVel += alphaAcc * dt;
rib.alpha = clamp(rib.alpha + rib.alphaVel * dt, 0.35, 2.72);
if (rib.alpha <= 0.35 || rib.alpha >= 2.72) rib.alphaVel *= -0.2;
// Outer rib flexural bend
const bendTargetBias = rib.inverted ? 0.16 : 0.0;
const bendSpring = -18.5 * (rib.bend - bendTargetBias) - 0.65 * rib.bendVel;
rib.bendVel += (tipBendTorque * 0.55 + bendSpring) / 0.012 * dt;
rib.bend = clamp(rib.bend + rib.bendVel * dt, -0.45, 0.68);
rib.invertFlash = Math.max(0, rib.invertFlash - dt * 2.8);
});
umbrella.lastAerForce.x = netFx;
umbrella.lastAerForce.y = netFy;
// ---------- Person Grip vs Free Flight ----------
if (umbrella.attached) {
// Target hand position near person's front chest/shoulder
const shX = person.hipX + 0.52 * Math.sin(person.lean);
const shY = person.hipY + 0.52 * Math.cos(person.lean);
const twoHanded = wind.current > 7.5 || umbrella.state === "inverted";
// Person lowers and anchors handle closer when gale rises
const bracePull = clamp((wind.current - 5) / 22, 0, 1);
const targetHx = shX + lerp(0.32, 0.20, bracePull);
const targetHy = shY + lerp(-0.14, -0.02, bracePull);
// Target shaft angle: person instinctively angles umbrella INTO the wind (negative theta)
const targetTheta = clamp(-0.06 - wind.current * 0.019 - (umbrella.ribs[0].inverted ? 0.15 : 0), -0.62, 0.05);
// Spring-damper grip at handle
const kGrip = twoHanded ? 4200 : 2800;
const cGrip = twoHanded ? 160 : 115;
let gripFx = kGrip * (targetHx - umbrella.hx) - cGrip * umbrella.vx;
let gripFy = kGrip * (targetHy - umbrella.hy) - cGrip * umbrella.vy;
// Wrist torque at handle
const kWrist = twoHanded ? 185 : 95;
const cWrist = twoHanded ? 16 : 9;
let wristTorque = kWrist * (targetTheta - umbrella.theta) - cWrist * umbrella.omega;
const maxWristTorque = twoHanded ? 78 : 34;
// If wrist torque saturates, the umbrella gets wrenched sideways/backward!
wristTorque = clamp(wristTorque, -maxWristTorque, maxWristTorque);
// Cap grip force: when exceeded persistently, fingers slip and umbrella tears away!
const maxGripForce = twoHanded ? 465 : 270;
const gripMag = Math.hypot(gripFx, gripFy);
umbrella.gripRatio = clamp(gripMag / maxGripForce, 0, 1.35);
if (gripMag > maxGripForce) {
const scale = maxGripForce / gripMag;
gripFx *= scale;
gripFy *= scale;
umbrella.overloadTime += dt * (0.6 + (gripMag - maxGripForce) / 140);
} else {
umbrella.overloadTime = Math.max(0, umbrella.overloadTime - dt * 1.8);
}
if (umbrella.overloadTime > 0.24) {
// Torn loose!
umbrella.attached = false;
umbrella.state = "lost";
umbrella.vx += 2.2;
umbrella.vy += 1.4;
umbrella.omega += 5.5;
person.recoilTimer = 0.7;
person.handLVel.x = 1.6;
person.handLVel.y = 1.4;
person.handRVel.x = 1.9;
person.handRVel.y = 1.6;
playSnapSound(1.2);
}
umbrella.lastGripForce.x = gripFx;
umbrella.lastGripForce.y = gripFy;
// Total force on umbrella
const totalFx = netFx + gripFx;
const totalFy = netFy + gripFy;
// Grip force acts at handle (hx, hy), which is offset from CoM (comX, comY)
const rHx = umbrella.hx - comX;
const rHy = umbrella.hy - comY;
const gripTorqueAroundCoM = rHy * gripFx - rHx * gripFy;
umbrella.vx += (totalFx / umbrella.mass) * dt;
umbrella.vy += (totalFy / umbrella.mass) * dt;
umbrella.omega += ((netTorque + gripTorqueAroundCoM + wristTorque) / umbrella.inertia) * dt;
// Subtle air damping on rotation
umbrella.omega *= Math.exp(-2.2 * dt);
umbrella.hx += umbrella.vx * dt;
umbrella.hy += umbrella.vy * dt;
umbrella.theta = clamp(umbrella.theta + umbrella.omega * dt, -1.35, 1.45);
// Update status label state
if (umbrella.ribs[0].inverted || umbrella.ribs[1].inverted) {
umbrella.state = "inverted";
} else if (
Math.abs(umbrella.theta - targetTheta) > 0.22 ||
umbrella.ribs[0].alpha > 1.38 ||
umbrella.gripRatio > 0.72
) {
umbrella.state = "straining";
} else {
umbrella.state = "normal";
}
} else {
// Free-flying lost umbrella!
umbrella.gripRatio = 0;
umbrella.lastGripForce.x = 0;
umbrella.lastGripForce.y = 0;
umbrella.lostTimer += dt;
umbrella.vx += (netFx / umbrella.mass) * dt;
umbrella.vy += (netFy / umbrella.mass) * dt;
umbrella.omega += (netTorque / umbrella.inertia) * dt;
umbrella.omega *= Math.exp(-0.7 * dt);
umbrella.hx += umbrella.vx * dt;
umbrella.hy += umbrella.vy * dt;
umbrella.theta += umbrella.omega * dt;
// Ground bounce if it tumbles low
if (umbrella.hy < 0.08) {
umbrella.hy = 0.08;
umbrella.vy = Math.abs(umbrella.vy) * 0.45 + 0.4;
umbrella.vx *= 0.85;
umbrella.omega += (Math.random() - 0.5) * 3;
}
const tipLow = Math.min(geom.crownY, geom.wings[0].tipY, geom.wings[1].tipY);
if (tipLow < 0.04) {
umbrella.hy += 0.04 - tipLow;
umbrella.vy = Math.max(umbrella.vy, 0.7);
umbrella.omega *= 0.6;
}
}
}
// ---------- Person Balance, Footwork & Soft Cloth ----------
function stepPersonPhysics(dt) {
const bodyMass = 68;
// Horizontal drag on person's body & coat
const bodyDrag = 0.5 * AIR_RHO * 0.95 * 0.58 * wind.current * wind.current;
// Force transmitted from umbrella handle into hands
const umbPullX = umbrella.attached ? -umbrella.lastGripForce.x : 0;
const umbPullY = umbrella.attached ? -umbrella.lastGripForce.y : 0;
const totalHorizPush = bodyDrag + umbPullX;
// Desired crouch & lean angle to balance wind drag + umbrella pull with body weight!
// Torque around feet: totalHorizPush * ~1.0m = bodyMass * g * 0.95m * (-leanOffset)
const effectiveWeight = bodyMass * GRAVITY - umbPullY * 0.7;
const desiredLean = clamp(
-Math.atan2(totalHorizPush * 1.05, Math.max(320, effectiveWeight * 0.92)),
-0.46,
0.14
);
const leanSpring = 36 * (desiredLean - person.lean) - 9.2 * person.leanVel;
person.leanVel += leanSpring * dt;
person.lean = clamp(person.lean + person.leanVel * dt, -0.52, 0.25);
// Crouch deepens in strong gales to lower center of gravity
const targetCrouch = clamp((wind.current - 9) / 26, 0, 0.14) + (umbrella.attached && umbrella.state === "inverted" ? 0.04 : 0);
person.crouch = lerp(person.crouch, targetCrouch, 1 - Math.exp(-5 * dt));
// Foot bracing: spread feet wider as wind increases!
const stanceWidth = lerp(0.24, 0.64, clamp((wind.current - 3) / 24, 0, 1));
const shiftBias = clamp(totalHorizPush / 450, 0, 0.16);
const idealLeftFootX = person.anchorX - stanceWidth * 0.42 - shiftBias;
const idealRightFootX = person.anchorX + stanceWidth * 0.58 - shiftBias;
person.stepCooldown = Math.max(0, person.stepCooldown - dt);
function updateFoot(foot, idealX, otherMoving) {
if (foot.progress < 1) {
foot.progress = Math.min(1, foot.progress + dt * 4.8);
const t = foot.progress;
const smooth = t * t * (3 - 2 * t);
foot.x = lerp(foot.startX, foot.targetX, smooth);
foot.lift = Math.sin(t * Math.PI) * 0.085;
foot.y = foot.lift;
} else {
foot.y = 0;
if (!otherMoving && person.stepCooldown <= 0 && Math.abs(foot.x - idealX) > 0.11) {
foot.startX = foot.x;
foot.targetX = idealX;
foot.progress = 0;
person.stepCooldown = 0.16;
}
}
}
const rMoving = person.footR.progress < 1;
const lMoving = person.footL.progress < 1;
updateFoot(person.footR, idealRightFootX, lMoving);
updateFoot(person.footL, idealLeftFootX, person.footR.progress < 1);
// Hip follows center between planted feet + slight lean shift
const feetMidX = (person.footL.x + person.footR.x) * 0.5;
const stanceSpan = Math.abs(person.footR.x - person.footL.x);
const crouchDrop = person.crouch + Math.max(0, stanceSpan - 0.35) * 0.18;
const targetHipY = 0.84 - crouchDrop;
const targetHipX = feetMidX + Math.sin(person.lean) * 0.24;
person.hipX = lerp(person.hipX, targetHipX, 1 - Math.exp(-10 * dt));
person.hipY = lerp(person.hipY, targetHipY, 1 - Math.exp(-10 * dt));
// Hands
const shX = person.hipX + 0.52 * Math.sin(person.lean);
const shY = person.hipY + 0.52 * Math.cos(person.lean);
if (umbrella.attached) {
person.handR.x = umbrella.hx;
person.handR.y = umbrella.hy;
const twoHanded = wind.current > 7.5 || umbrella.state === "inverted";
if (twoHanded) {
const ux = Math.sin(umbrella.theta);
const uy = Math.cos(umbrella.theta);
person.handL.x = lerp(person.handL.x, umbrella.hx + ux * 0.075, 1 - Math.exp(-16 * dt));
person.handL.y = lerp(person.handL.y, umbrella.hy + uy * 0.075, 1 - Math.exp(-16 * dt));
} else {
// Left arm balances gently at coat/side
const idleLX = shX - 0.18 - Math.sin(wind.time * 2) * 0.02;
const idleLY = shY - 0.42;
person.handL.x = lerp(person.handL.x, idleLX, 1 - Math.exp(-8 * dt));
person.handL.y = lerp(person.handL.y, idleLY, 1 - Math.exp(-8 * dt));
}
} else {
// Recoil & watch it go!
person.recoilTimer = Math.max(0, person.recoilTimer - dt);
const settleX = shX + 0.22;
const settleY = shY + 0.04;
person.handRVel.x += (30 * (settleX - person.handR.x) - 6 * person.handRVel.x) * dt;
person.handRVel.y += (30 * (settleY - person.handR.y) - 6 * person.handRVel.y) * dt;
person.handR.x += person.handRVel.x * dt;
person.handR.y += person.handRVel.y * dt;
person.handLVel.x += (26 * (settleX - 0.10 - person.handL.x) - 6 * person.handLVel.x) * dt;
person.handLVel.y += (26 * (settleY - 0.08 - person.handL.y) - 6 * person.handLVel.y) * dt;
person.handL.x += person.handLVel.x * dt;
person.handL.y += person.handLVel.y * dt;
}
// Coat & Scarf Verlet chains
function stepChain(nodes, anchorX, anchorY, segLen, dragScale, flutterAmt) {
nodes[0].x = anchorX;
nodes[0].y = anchorY;
for (let i = 1; i < nodes.length; i++) {
const n = nodes[i];
const vx = (n.x - n.ox) * 0.96;
const vy = (n.y - n.oy) * 0.96;
n.ox = n.x;
n.oy = n.y;
const w = computeWindAt(n.x, Math.max(0.1, n.y));
const wxForce = dragScale * w.vx * Math.abs(w.vx);
const wyFlutter = Math.sin(wind.time * (9 + i * 2.1) - i * 1.1) * flutterAmt * (0.2 + wind.current * 0.09);
n.x += vx + wxForce * dt * dt;
n.y += vy + (-GRAVITY * 0.85 + wyFlutter) * dt * dt;
}
for (let iter = 0; iter < 4; iter++) {
nodes[0].x = anchorX;
nodes[0].y = anchorY;
for (let i = 1; i < nodes.length; i++) {
const a = nodes[i - 1];
const b = nodes[i];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1e-5;
const diff = (d - segLen) / d;
if (i === 1) {
b.x -= dx * diff;
b.y -= dy * diff;
} else {
a.x += dx * diff * 0.35;
a.y += dy * diff * 0.35;
b.x -= dx * diff * 0.65;
b.y -= dy * diff * 0.65;
}
if (b.y < 0.04) b.y = 0.04;
}
}
}
stepChain(person.coatNodes, person.hipX - 0.03, person.hipY + 0.06, 0.072, 0.19, 4.2);
stepChain(person.scarfNodes, shX + 0.02 * Math.sin(person.lean), shY + 0.04, 0.068, 0.32, 7.5);
}
// ---------- Atmosphere & Rain Step ----------
function stepAtmosphere(dt) {
const geom = getUmbrellaPoints();
const minX = Math.min(geom.wings[0].tipX, geom.crownX, geom.wings[1].tipX);
const maxX = Math.max(geom.wings[0].tipX, geom.crownX, geom.wings[1].tipX);
const minY = Math.min(geom.wings[0].tipY, geom.wings[1].tipY);
const maxY = Math.max(geom.crownY, geom.wings[0].tipY, geom.wings[1].tipY);
for (let i = 0; i < raindrops.length; i++) {
const r = raindrops[i];
const w = computeWindAt(r.x, r.y);
const fallSpeed = 9.2 * r.speedBias;
r.x += w.vx * dt;
r.y -= fallSpeed * dt;
// Canopy collision & rain shedding
if (r.x > minX && r.x < maxX && r.y > minY && r.y < maxY + 0.06) {
// Simple roof check against dome height
const t = clamp((r.x - geom.wings[0].tipX) / ((geom.wings[1].tipX - geom.wings[0].tipX) || 0.01), 0, 1);
const roofY = lerp(
t < 0.5 ? geom.wings[0].tipY : geom.crownY,
t < 0.5 ? geom.crownY : geom.wings[1].tipY,
t < 0.5 ? t * 2 : (t - 0.5) * 2
);
if (Math.abs(r.y - roofY) < 0.08) {
if (Math.random() < 0.45) {
spawnSplash(r.x, roofY, w.vx * 0.25, 0.4, 1, true);
}
r.y = 4.6 + Math.random() * 0.8;
r.x = Math.random() * 14 - 5 - wind.current * 0.22;
continue;
}
}
if (r.y <= 0) {
if (Math.random() < 0.28 && r.x > -3.5 && r.x < 5.5) {
spawnSplash(r.x, 0.01, w.vx * 0.08, 0.15, 1, false);
}
r.y = 4.5 + Math.random() * 1.0;
r.x = Math.random() * 14 - 5 - wind.current * 0.24;
}
if (r.x > 8.5) {
r.x -= 14;
}
}
for (let i = splashes.length - 1; i >= 0; i--) {
const s = splashes[i];
s.vy -= GRAVITY * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.life -= dt;
if (s.y < 0) {
s.y = 0;
s.vy = 0;
}
if (s.life <= 0) {
splashes.splice(i, 1);
}
}
for (let i = 0; i < windStreaks.length; i++) {
const st = windStreaks[i];
st.x += (wind.current * st.speed + 1.5) * dt;
st.y += Math.sin(wind.time * 3 + i) * 0.12 * dt;
if (st.x > 8) {
st.x = -5 - Math.random() * 2;
st.y = 0.08 + Math.random() * 3.9;
}
}
for (let i = 0; i < clouds.length; i++) {
const c = clouds[i];
c.x += (c.speed + wind.current * 0.016) * dt;
if (c.x - c.w > 8) {
c.x = -5 - c.w;
}
}
for (let i = 0; i < trees.length; i++) {
const tr = trees[i];
const w = computeWindAt(tr.x, tr.height * 0.7);
const targetSway = clamp(0.0014 * w.vx * w.vx + Math.sin(wind.time * 2.4 + tr.seed) * 0.02 * (wind.current / 15), 0, 0.55);
const spring = 18 * (targetSway - tr.sway) - 3.6 * tr.swayVel;
tr.swayVel += spring * dt;
tr.sway += tr.swayVel * dt;
}
}
// ---------- Wind & HUD Updates ----------
function getBeaufortLabel(ms) {
if (ms < 1.6) return ["Light Air", ""];
if (ms < 5.5) return ["Gentle Breeze", ""];
if (ms < 10.8) return ["Fresh Breeze", ""];
if (ms < 17.2) return ["Near Gale", "warn"];
if (ms < 24.5) return ["Gale", "warn"];
if (ms < 32.7) return ["Storm", "danger"];
return ["Hurricane Force", "danger"];
}
function updateWindAndHUD(dt) {
wind.time += dt;
// Keyboard wind control
const keyRate = 11.5;
if (keys.right) wind.base = clamp(wind.base + keyRate * dt, 0, 40);
if (keys.left) wind.base = clamp(wind.base - keyRate * dt, 0, 40);
// Automatic steady rise
if (wind.autoRise && !wind.sliderDragging && !keys.left && !keys.right) {
wind.base = clamp(wind.base + 0.55 * dt, 0, 40);
}
// Layered natural gusts
const g1 = Math.sin(wind.time * 0.63);
const g2 = Math.sin(wind.time * 1.71 + 1.4);
const g3 = Math.sin(wind.time * 3.85 + 2.7);
const gustWave = 0.55 * g1 + 0.30 * g2 + 0.15 * g3;
const gustAmp = wind.base * 0.16;
wind.gustFactor = gustWave;
wind.current = clamp(wind.base + gustWave * gustAmp, 0, 46);
if (!wind.sliderDragging) {
windSlider.value = wind.base.toFixed(1);
}
umbrella.flutterPhase += dt * (4 + wind.current * 0.95);
wind.cameraShake = Math.max(0, wind.cameraShake - dt * 0.08);
if (wind.current > 22) {
wind.cameraShake = Math.max(wind.cameraShake, (wind.current - 22) * 0.00028);
}
// Audio modulation
if (audio.enabled && audio.ctx) {
const norm = clamp(wind.current / 36, 0, 1);
audio.windFilter.frequency.setTargetAtTime(170 + norm * 620, audio.ctx.currentTime, 0.08);
audio.windGain.gain.setTargetAtTime(0.02 + Math.pow(norm, 1.45) * 0.34, audio.ctx.currentTime, 0.08);
audio.rainGain.gain.setTargetAtTime(0.05 + norm * 0.14, audio.ctx.currentTime, 0.08);
}
// DOM HUD
windMpsEl.textContent = wind.current.toFixed(1);
windMphEl.textContent = `${Math.round(wind.current * 2.23694)} mph`;
windKmhEl.textContent = `${Math.round(wind.current * 3.6)} km/h`;
const [bLabel, bClass] = getBeaufortLabel(wind.current);
beaufortBadgeEl.textContent = bLabel;
beaufortBadgeEl.className = "badge" + (bClass ? " " + bClass : "");
if (!umbrella.attached) {
umbStatusBadgeEl.textContent = "Umbrella: Blown Away!";
umbStatusBadgeEl.className = "badge danger";
} else if (umbrella.state === "inverted") {
umbStatusBadgeEl.textContent = "Umbrella: Inside-Out";
umbStatusBadgeEl.className = "badge danger";
} else if (umbrella.state === "straining") {
umbStatusBadgeEl.textContent = "Umbrella: Straining";
umbStatusBadgeEl.className = "badge warn";
} else {
umbStatusBadgeEl.textContent = "Umbrella: Intact";
umbStatusBadgeEl.className = "badge";
}
const gripPct = clamp(umbrella.gripRatio * 100, 0, 100);
gripMeterEl.style.width = `${gripPct.toFixed(0)}%`;
gripMeterEl.style.backgroundColor = gripPct > 82 ? "#f05a5a" : gripPct > 55 ? "#f5b041" : "#59b4ff";
gustNoteEl.textContent = gustWave > 0.35 && wind.current > 6
? "Strong gust!"
: wind.autoRise
? "Wind rising over time"
: "Manual wind hold";
}
// ---------- Rendering ----------
function render() {
ctx.save();
ctx.scale(dpr, dpr);
// Subtle gale camera vibration
const shakeX = (Math.random() - 0.5) * wind.cameraShake * PPM;
const shakeY = (Math.random() - 0.5) * wind.cameraShake * PPM;
ctx.translate(shakeX, shakeY);
// 1. Stormy Sky Gradient
const stormMix = clamp(wind.current / 35, 0, 1);
const skyGrad = ctx.createLinearGradient(0, 0, 0, originY);
skyGrad.addColorStop(0, `rgb(${Math.round(lerp(18, 10, stormMix))}, ${Math.round(lerp(31, 17, stormMix))}, ${Math.round(lerp(49, 26, stormMix))})`);
skyGrad.addColorStop(0.65, `rgb(${Math.round(lerp(35, 22, stormMix))}, ${Math.round(lerp(54, 34, stormMix))}, ${Math.round(lerp(76, 48, stormMix))})`);
skyGrad.addColorStop(1, `rgb(${Math.round(lerp(48, 30, stormMix))}, ${Math.round(lerp(66, 42, stormMix))}, ${Math.round(lerp(86, 56, stormMix))})`);
ctx.fillStyle = skyGrad;
ctx.fillRect(-20, -20, width + 40, height + 40);
// 2. Drifting Storm Clouds
for (let i = 0; i < clouds.length; i++) {
const c = clouds[i];
const [cx, cy] = worldToScreen(c.x, c.y);
ctx.fillStyle = `rgba(14, 22, 34, ${0.35 + c.shade + stormMix * 0.25})`;
ctx.beginPath();
ctx.ellipse(cx, cy, c.w * PPM * 0.5, c.h * PPM * 0.5, 0, 0, Math.PI * 2);
ctx.fill();
}
// 3. Distant Swaying Trees Silhouette
for (let i = 0; i < trees.length; i++) {
const tr = trees[i];
const [bx, by] = worldToScreen(tr.x, 0);
const hPx = tr.height * PPM;
const swayPx = Math.sin(tr.sway) * hPx * 0.72;
const topY = by - Math.cos(tr.sway) * hPx;
ctx.strokeStyle = "rgba(13, 21, 31, 0.8)";
ctx.lineWidth = Math.max(3, 0.07 * PPM);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.quadraticCurveTo(bx + swayPx * 0.35, by - hPx * 0.55, bx + swayPx, topY);
ctx.stroke();
ctx.fillStyle = "rgba(15, 25, 37, 0.75)";
ctx.beginPath();
ctx.ellipse(
bx + swayPx * 0.9,
topY + hPx * 0.08,
hPx * 0.32,
hPx * 0.22,
tr.sway * 0.85,
0,
Math.PI * 2
);
ctx.fill();
}
// 4. Streetlamp on the left casting warm wet-night atmosphere
const [lampX, lampBaseY] = worldToScreen(-2.35, 0);
const lampTopY = lampBaseY - 2.65 * PPM;
ctx.strokeStyle = "#1a2534";
ctx.lineWidth = Math.max(3, 0.045 * PPM);
ctx.beginPath();
ctx.moveTo(lampX, lampBaseY);
ctx.lineTo(lampX, lampTopY);
ctx.quadraticCurveTo(lampX, lampTopY - 0.18 * PPM, lampX + 0.25 * PPM, lampTopY - 0.16 * PPM);
ctx.stroke();
const lanternX = lampX + 0.26 * PPM;
const lanternY = lampTopY - 0.13 * PPM;
const glow = ctx.createRadialGradient(lanternX, lanternY, 2, lanternX, lanternY, 1.9 * PPM);
glow.addColorStop(0, "rgba(255, 214, 130, 0.55)");
glow.addColorStop(0.2, "rgba(255, 188, 92, 0.16)");
glow.addColorStop(1, "rgba(255, 170, 70, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(lanternX, lanternY, 1.9 * PPM, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffe6aa";
ctx.beginPath();
ctx.arc(lanternX, lanternY, 0.042 * PPM, 0, Math.PI * 2);
ctx.fill();
// 5. Wet Pavement Ground & Puddle Reflections
const groundGrad = ctx.createLinearGradient(0, originY, 0, height);
groundGrad.addColorStop(0, "#151f2d");
groundGrad.addColorStop(0.25, "#0f1722");
groundGrad.addColorStop(1, "#090e15");
ctx.fillStyle = groundGrad;
ctx.fillRect(-20, originY, width + 40, height - originY + 20);
// Specular streetlamp reflection on wet asphalt
const reflGrad = ctx.createRadialGradient(lanternX + 0.3 * PPM, originY + 12, 4, lanternX + 0.5 * PPM, originY + 28, 1.8 * PPM);
reflGrad.addColorStop(0, "rgba(255, 194, 96, 0.22)");
reflGrad.addColorStop(1, "rgba(255, 194, 96, 0)");
ctx.fillStyle = reflGrad;
ctx.fillRect(-20, originY, width + 40, height - originY + 20);
// Crisp curb/puddle highlight line
ctx.strokeStyle = "rgba(130, 175, 220, 0.16)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-20, originY);
ctx.lineTo(width + 20, originY);
ctx.stroke();
// 6. Wind Speed Streaks (mist/gust lines)
if (wind.current > 4) {
const streakVis = clamp((wind.current - 4) / 28, 0, 1);
ctx.lineCap = "round";
for (let i = 0; i < windStreaks.length; i++) {
const st = windStreaks[i];
const [sx, sy] = worldToScreen(st.x, st.y);
ctx.strokeStyle = `rgba(195, 222, 250, ${st.alpha * streakVis})`;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + st.len * PPM * (0.4 + streakVis), sy);
ctx.stroke();
}
}
// 7. Person (Legs, Coat, Torso, Scarf, Head, Arms)
drawPerson();
// 8. Umbrella (Shaft, Stretchers, Flexing Ribs, Canopy)
drawUmbrella();
// 9. Foreground Rain Streaks & Splashes
drawRainAndSplashes();
ctx.restore();
}
function drawPerson() {
const shX = person.hipX + 0.52 * Math.sin(person.lean);
const shY = person.hipY + 0.52 * Math.cos(person.lean);
const neckX = shX + 0.08 * Math.sin(person.lean);
const neckY = shY + 0.08 * Math.cos(person.lean);
// Head turns slightly downwind if umbrella flew away
const headX = neckX + 0.09 * Math.sin(person.lean * 0.7);
const headY = neckY + 0.09 * Math.cos(person.lean * 0.7);
// Solve Leg IKs (knee bends forward -> bendSign = +1 or -1 depending on orientation; facing right +x, knee bends +x => bendSign = -1 because thigh goes down)
// Check: from hip (0, 0.8) to foot (0, 0), vector u = (0, -1), perp (-uy, ux) = (+1, 0) -> bendSign = +1 puts knee at +x (forward)!
const hipLX = person.hipX - 0.03;
const hipRX = person.hipX + 0.03;
const legL = solve2BoneIK(hipLX, person.hipY, person.footL.x, person.footL.y + 0.04, 0.43, 0.43, 1);
const legR = solve2BoneIK(hipRX, person.hipY, person.footR.x, person.footR.y + 0.04, 0.43, 0.43, 1);
// Solve Arm IKs (elbow bends backward/down -> bendSign = -1)
const armL = solve2BoneIK(shX - 0.02, shY - 0.03, person.handL.x, person.handL.y, 0.29, 0.28, -1);
const armR = solve2BoneIK(shX + 0.02, shY - 0.03, person.handR.x, person.handR.y, 0.29, 0.28, -1);
// Back Arm (Left arm)
drawLimb(shX - 0.02, shY - 0.03, armL.jx, armL.jy, armL.ex, armL.ey, 0.078, "#b78319");
// Back Leg (Left leg)
drawLeg(hipLX, person.hipY, legL.jx, legL.jy, legL.ex, legL.ey, "#17202c", "#111720");
// Front Leg (Right leg)
drawLeg(hipRX, person.hipY, legR.jx, legR.jy, legR.ex, legR.ey, "#223042", "#18202c");
// Billowing Raincoat Tail (Verlet ribbon)
ctx.fillStyle = "#d99d21";
ctx.strokeStyle = "#9b6d11";
ctx.lineWidth = 1.5;
ctx.beginPath();
const [hipSx, hipSy] = worldToScreen(person.hipX, person.hipY + 0.05);
const [shSx, shSy] = worldToScreen(shX, shY);
ctx.moveTo(shSx - 0.09 * PPM, shSy);
ctx.lineTo(shSx + 0.10 * PPM, shSy);
ctx.lineTo(hipSx + 0.11 * PPM, hipSy);
for (let i = 1; i < person.coatNodes.length; i++) {
const [cx, cy] = worldToScreen(person.coatNodes[i].x + 0.09, person.coatNodes[i].y);
ctx.lineTo(cx, cy);
}
for (let i = person.coatNodes.length - 1; i >= 1; i--) {
const [cx, cy] = worldToScreen(person.coatNodes[i].x - 0.11, person.coatNodes[i].y);
ctx.lineTo(cx, cy);
}
ctx.lineTo(hipSx - 0.11 * PPM, hipSy);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Torso coat body
ctx.strokeStyle = "#e6ac2c";
ctx.lineWidth = 0.20 * PPM;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(hipSx, hipSy);
ctx.lineTo(shSx, shSy);
ctx.stroke();
// Scarf whipping in wind
ctx.strokeStyle = "#b83539";
ctx.lineWidth = Math.max(4, 0.055 * PPM);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
for (let i = 0; i < person.scarfNodes.length; i++) {
const [sx, sy] = worldToScreen(person.scarfNodes[i].x, person.scarfNodes[i].y);
if (i === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
// Head & Rain Hood
const [hx, hy] = worldToScreen(headX, headY);
ctx.fillStyle = "#eac3a2";
ctx.beginPath();
ctx.arc(hx + 0.02 * PPM, hy, 0.095 * PPM, 0, Math.PI * 2);
ctx.fill();
// Mustard raincoat hood framing back of head
ctx.fillStyle = "#e6ac2c";
ctx.strokeStyle = "#9b6d11";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(hx - 0.01 * PPM, hy - 0.005 * PPM, 0.112 * PPM, Math.PI * 0.48, Math.PI * 1.72);
ctx.lineTo(hx + 0.03 * PPM, hy + 0.08 * PPM);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Eye looking toward umbrella / into wind
ctx.fillStyle = "#1c2430";
ctx.beginPath();
ctx.arc(hx + 0.068 * PPM, hy - 0.012 * PPM, Math.max(1.5, 0.014 * PPM), 0, Math.PI * 2);
ctx.fill();
// Front Arm (Right arm)
drawLimb(shX + 0.02, shY - 0.03, armR.jx, armR.jy, armR.ex, armR.ey, 0.082, "#e6ac2c");
// Hands
const [hlx, hly] = worldToScreen(armL.ex, armL.ey);
const [hrx, hry] = worldToScreen(armR.ex, armR.ey);
ctx.fillStyle = "#eac3a2";
ctx.beginPath();
ctx.arc(hlx, hly, 0.036 * PPM, 0, Math.PI * 2);
ctx.arc(hrx, hry, 0.038 * PPM, 0, Math.PI * 2);
ctx.fill();
}
function drawLimb(ax, ay, jx, jy, ex, ey, thickM, color) {
const [sx0, sy0] = worldToScreen(ax, ay);
const [sx1, sy1] = worldToScreen(jx, jy);
const [sx2, sy2] = worldToScreen(ex, ey);
ctx.strokeStyle = color;
ctx.lineWidth = thickM * PPM;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(sx0, sy0);
ctx.lineTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
ctx.stroke();
}
function drawLeg(ax, ay, jx, jy, ex, ey, pantColor, bootColor) {
drawLimb(ax, ay, jx, jy, ex, ey, 0.095, pantColor);
const [fx, fy] = worldToScreen(ex, ey);
// Rain boot
ctx.fillStyle = bootColor;
ctx.beginPath();
ctx.ellipse(fx + 0.04 * PPM, fy + 0.01 * PPM, 0.085 * PPM, 0.04 * PPM, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawUmbrella() {
const g = getUmbrellaPoints();
const [hsx, hsy] = worldToScreen(umbrella.hx, umbrella.hy);
const [csx, csy] = worldToScreen(g.crownX, g.crownY);
const [rsx, rsy] = worldToScreen(g.runnerX, g.runnerY);
const [tipFarX, tipFarY] = worldToScreen(
umbrella.hx + g.ux * (umbrella.shaftLen + 0.075),
umbrella.hy + g.uy * (umbrella.shaftLen + 0.075)
);
const lw = g.wings[0];
const rw = g.wings[1];
const [lmx, lmy] = worldToScreen(lw.midX, lw.midY);
const [ltx, lty] = worldToScreen(lw.tipX, lw.tipY);
const [rmx, rmy] = worldToScreen(rw.midX, rw.midY);
const [rtx, rty] = worldToScreen(rw.tipX, rw.tipY);
// Back canopy interior shading (creates 3D dome depth)
const midInnerX = (ltx + rtx) * 0.5;
const midInnerY = (lty + rty) * 0.5;
ctx.fillStyle = "rgba(82, 18, 28, 0.78)";
ctx.beginPath();
ctx.moveTo(ltx, lty);
ctx.quadraticCurveTo(lmx, lmy, csx, csy);
ctx.quadraticCurveTo(rmx, rmy, rtx, rty);
ctx.quadraticCurveTo(midInnerX, midInnerY + 0.05 * PPM, ltx, lty);
ctx.closePath();
ctx.fill();
// Steel shaft
ctx.strokeStyle = "#c8d5e5";
ctx.lineWidth = Math.max(2.5, 0.018 * PPM);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(hsx, hsy);
ctx.lineTo(tipFarX, tipFarY);
ctx.stroke();
// Curved wooden J-handle
const handleBackX = hsx - g.ux * 0.09 * PPM - g.vx * 0.045 * PPM;
const handleBackY = hsy + g.uy * 0.09 * PPM + g.vy * 0.045 * PPM;
ctx.strokeStyle = "#8a5229";
ctx.lineWidth = Math.max(4, 0.032 * PPM);
ctx.beginPath();
ctx.moveTo(hsx, hsy);
ctx.quadraticCurveTo(
hsx - g.ux * 0.11 * PPM,
hsy + g.uy * 0.11 * PPM,
handleBackX,
handleBackY
);
ctx.stroke();
// Runner collar & Stretcher struts (visibly show linkage!)
ctx.fillStyle = "#9fb1c7";
ctx.beginPath();
ctx.arc(rsx, rsy, Math.max(3, 0.022 * PPM), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(210, 224, 240, 0.85)";
ctx.lineWidth = Math.max(1.5, 0.011 * PPM);
ctx.beginPath();
ctx.moveTo(rsx, rsy);
ctx.lineTo(lmx, lmy);
ctx.moveTo(rsx, rsy);
ctx.lineTo(rmx, rmy);
// Center back stretcher for 3D cue
ctx.moveTo(rsx, rsy);
ctx.lineTo((lmx + rmx) * 0.5, (lmy + rmy) * 0.5 + 0.03 * PPM);
ctx.stroke();
// Ribs (inner + outer flexing segments)
ctx.strokeStyle = "#cfdbe8";
ctx.lineWidth = Math.max(1.8, 0.013 * PPM);
ctx.beginPath();
ctx.moveTo(csx, csy);
ctx.quadraticCurveTo(lmx, lmy, ltx, lty);
ctx.moveTo(csx, csy);
ctx.quadraticCurveTo(rmx, rmy, rtx, rty);
ctx.stroke();
// Outer canopy panels (rich crimson with storm-light sheen)
const canopyGrad = ctx.createLinearGradient(ltx, csy, rtx, rty);
canopyGrad.addColorStop(0, "#e6474c");
canopyGrad.addColorStop(0.35, "#cc2b36");
canopyGrad.addColorStop(1, "#871923");
ctx.fillStyle = canopyGrad;
ctx.strokeStyle = "#5c1118";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(ltx, lty);
ctx.quadraticCurveTo(lmx, lmy, csx, csy);
ctx.quadraticCurveTo(rmx, rmy, rtx, rty);
// Scalloped bottom edge with foreshortened center rib
const midFrontX = (ltx + rtx) * 0.5 + (g.vx * 0.02 * PPM);
const midFrontY = (lty + rty) * 0.5 + (lw.rib.inverted ? -0.05 * PPM : 0.06 * PPM);
ctx.quadraticCurveTo((midFrontX + rtx) * 0.5, (midFrontY + rty) * 0.5 - 0.02 * PPM, midFrontX, midFrontY);
ctx.quadraticCurveTo((midFrontX + ltx) * 0.5, (midFrontY + lty) * 0.5 - 0.02 * PPM, ltx, lty);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Foreshortened center rib seam on canopy
ctx.strokeStyle = "rgba(70, 12, 18, 0.6)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(csx, csy);
ctx.quadraticCurveTo(
(csx + midFrontX) * 0.5 + ((lmx + rmx) * 0.5 - (csx + midFrontX) * 0.5) * 0.8,
(csy + midFrontY) * 0.5 + ((lmy + rmy) * 0.5 - (csy + midFrontY) * 0.5) * 0.8,
midFrontX,
midFrontY
);
ctx.stroke();
// Snap flash highlight when a rib flips
const flash = Math.max(lw.rib.invertFlash, rw.rib.invertFlash);
if (flash > 0.01) {
ctx.strokeStyle = `rgba(255, 240, 200, ${flash * 0.7})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(ltx, lty);
ctx.quadraticCurveTo(lmx, lmy, csx, csy);
ctx.quadraticCurveTo(rmx, rmy, rtx, rty);
ctx.stroke();
}
}
function drawRainAndSplashes() {
ctx.lineCap = "round";
for (let i = 0; i < raindrops.length; i++) {
const r = raindrops[i];
const w = computeWindAt(r.x, r.y);
const fall = 9.2 * r.speedBias;
const speed = Math.hypot(w.vx, fall) || 1;
const dx = (w.vx / speed) * r.len;
const dy = (-fall / speed) * r.len;
const [sx, sy] = worldToScreen(r.x, r.y);
const [ex, ey] = worldToScreen(r.x - dx, r.y - dy);
ctx.strokeStyle = `rgba(186, 214, 244, ${r.alpha})`;
ctx.lineWidth = 1.15;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
}
for (let i = 0; i < splashes.length; i++) {
const s = splashes[i];
const a = clamp(s.life / s.maxLife, 0, 1);
const [sx, sy] = worldToScreen(s.x, s.y);
ctx.fillStyle = s.bright
? `rgba(225, 240, 255, ${a * 0.85})`
: `rgba(170, 205, 240, ${a * 0.55})`;
ctx.beginPath();
ctx.arc(sx, sy, s.bright ? 1.8 : 1.3, 0, Math.PI * 2);
ctx.fill();
}
}
// ---------- Main Loop (Fixed Sub-stepping) ----------
let lastTime = performance.now();
function frame(now) {
const rawDt = (now - lastTime) * 0.001;
lastTime = now;
const dt = Math.min(rawDt, 0.04);
updateWindAndHUD(dt);
const subSteps = 6;
const subDt = dt / subSteps;
for (let i = 0; i < subSteps; i++) {
stepUmbrellaPhysics(subDt);
stepPersonPhysics(subDt);
}
stepAtmosphere(dt);
render();
requestAnimationFrame(frame);
}
resetUmbrella();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Where it looks right:
Where it looks wrong:
R / New Umbrella, and wind only blows left-to-right, so you can never turn around or point the back of an inverted umbrella into a gust to pop it right-side-out again.