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 a parametrized silhouette driven by two bistable rib hinges: each half of the canopy is one angle φ measured from the shaft (55° = open dome, past 90° = rim above the crown, ~135° = flipped over), and the drawn canopy is the rib lines plus fabric-sag and wind-drape terms layered on top, filled between the rib curve and a hanging hem. Wind pressure enters as a W² force that pushes the upwind rib's angle upward (rim lifting, canopy flattening) with a softening clamp below ~17 m/s — so the frame visibly "loads up" and flutters at the brink without being able to fail. Inversion is a state change: once the upwind rib is driven past ~96° in wind above ~17 m/s, the rest angles flip to the inverted values and stiff, underdamped springs (plus a velocity impulse) whip the canopy through flat with overshoot — a snap, not a bend — and it stays inverted (hysteresis) until replaced. Whether it happens is decided by effective wind (base × Ornstein–Uhlenbeck gust noise × burst gusts) crossing that threshold on a gust; the same W² force, reduced by the person's tilt-into-wind and multiplied ~1.6× once inverted, is compared against a fixed grip strength, and when it exceeds it, the umbrella becomes a free ballistic body with quadratic drag and ground bounces, tumbling downwind. The person is a spring-damped pose machine — lean, stance widening with animated brace steps, skid, crouch, tremor — driven by that same force, always anchored to the ground. Rain is advected particles whose velocity is the wind, with segment collision against the canopy polyline (deflection spray, a dry shadow under the umbrella) and ripples where drops land.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gale — an umbrella, a person, and the wind</title>
<style>
:root{ --ink:#d6d0c4; --red:#c9553c; }
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{ height:100%; overflow:hidden; background:#151920; }
canvas{ position:fixed; inset:0; display:block; touch-action:none; }
.hud{ position:fixed; left:0; right:0; z-index:2; pointer-events:none; color:var(--ink);
text-shadow:0 1px 3px rgba(8,10,14,.65); }
header.hud{ top:0; padding:18px 22px; display:flex; justify-content:space-between;
align-items:flex-start; gap:16px; }
footer.hud{ bottom:0; padding:14px 22px; display:flex; justify-content:space-between;
align-items:flex-end; gap:12px; flex-wrap:wrap; }
h1{ font:italic 700 clamp(22px,3.4vw,30px)/1 Georgia,'Iowan Old Style','Times New Roman',serif;
letter-spacing:.5px; }
.sub{ font:10px/1.5 ui-monospace,'Cascadia Mono',Consolas,Menlo,monospace;
letter-spacing:.16em; text-transform:uppercase; opacity:.72; margin-top:5px; }
.state{ font:italic 15px/1.3 Georgia,serif; text-transform:none; letter-spacing:.02em;
opacity:.92; margin-top:9px; }
.state.bad{ color:var(--red); opacity:1; }
.readout{ text-align:right; }
.wline{ display:flex; align-items:baseline; justify-content:flex-end; gap:6px; }
.w{ font:600 clamp(30px,4.6vw,46px)/1 ui-monospace,'Cascadia Mono',Consolas,Menlo,monospace;
font-variant-numeric:tabular-nums; }
.unit{ font:11px ui-monospace,Consolas,monospace; opacity:.7; letter-spacing:.1em; }
.minor{ font:10.5px/1.7 ui-monospace,'Cascadia Mono',Consolas,monospace; letter-spacing:.12em;
text-transform:uppercase; opacity:.68; font-variant-numeric:tabular-nums; }
.minor.sev{ color:var(--red); opacity:1; }
.bar{ pointer-events:auto; display:flex; align-items:center; gap:10px; flex-wrap:wrap;
background:rgba(12,14,18,.44); border:1px solid rgba(214,208,196,.22);
border-radius:3px; padding:9px 13px; backdrop-filter:blur(5px); }
.bar .lbl{ font:10px ui-monospace,Consolas,monospace; letter-spacing:.18em;
text-transform:uppercase; opacity:.6; }
button{ font:10.5px ui-monospace,'Cascadia Mono',Consolas,monospace; letter-spacing:.14em;
text-transform:uppercase; color:var(--ink); background:none;
border:1px solid rgba(214,208,196,.38); border-radius:2px; padding:6px 10px;
cursor:pointer; }
button:hover{ border-color:var(--ink); background:rgba(214,208,196,.08); }
button.on{ background:var(--ink); color:#15181d; border-color:var(--ink); }
input[type=range]{ -webkit-appearance:none; appearance:none; width:min(300px,34vw);
height:20px; background:none; cursor:pointer; }
input[type=range]::-webkit-slider-runnable-track{ height:2px; background:rgba(214,208,196,.35); }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:13px; height:13px;
border-radius:50%; background:var(--ink); margin-top:-5.5px; border:none; }
input[type=range]::-moz-range-track{ height:2px; background:rgba(214,208,196,.35); }
input[type=range]::-moz-range-thumb{ width:13px; height:13px; border-radius:50%;
background:var(--ink); border:none; }
.hints{ font:10px/1.7 ui-monospace,Consolas,monospace; letter-spacing:.1em; opacity:.5;
text-align:right; text-transform:uppercase; }
@media (max-width:640px){ .hints{ display:none; } }
</style>
</head>
<body>
<canvas id="c"></canvas>
<header class="hud">
<div>
<h1>Gale</h1>
<div class="sub">an umbrella · a person · the wind</div>
<div class="sub">the wind rises on its own — hurry it, or spare it</div>
<div class="state" id="umbState">holding steady</div>
</div>
<div class="readout">
<div class="wline"><span class="w" id="w">4.0</span><span class="unit">m/s</span></div>
<div class="minor" id="kmh">14 km/h</div>
<div class="minor" id="gust"> </div>
<div class="minor" id="beaufort">F2 — light breeze</div>
</div>
</header>
<footer class="hud">
<div class="bar">
<button id="autoBtn" class="on" title="toggle automatic wind rise (A)">auto rise</button>
<span class="lbl">wind m/s</span>
<input id="slider" type="range" min="0" max="35" step="0.1" value="4" aria-label="wind speed">
<button id="gustBtn" title="throw a gust (space)">gust</button>
<button id="resetBtn" title="fresh umbrella (R)">new umbrella</button>
<button id="sndBtn" title="toggle sound (S)">sound: on</button>
</div>
<div class="hints">arrows / drag — wind · space — gust<br>
r — new umbrella · a — auto · s — sound · click — gust</div>
</footer>
<script>
(()=>{'use strict';
const TAU=Math.PI*2, RAD=Math.PI/180;
const cv=document.getElementById('c'), g=cv.getContext('2d');
const $=s=>document.getElementById(s);
const ui={w:$('w'),kmh:$('kmh'),gu:$('gust'),bf:$('beaufort'),st:$('umbState'),
auto:$('autoBtn'),sl:$('slider'),gb:$('gustBtn'),rb:$('resetBtn'),sb:$('sndBtn')};
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const gauss=()=>(Math.random()+Math.random()+Math.random()+Math.random()-2)*.9;
const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return{x:x/d,y:y/d};};
const mix=(a,b,t)=>[lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)];
const rgb=c=>`rgb(${c[0]|0},${c[1]|0},${c[2]|0})`;
const RM=typeof matchMedia==='function'&&matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ---------- world sizing ---------- */
let Wc=0,Hc=0,U=1,dpr=1,hy=0,fy=0,px0=0,treeX=0,lampX=0;
let roofs=[],puddles=[],leaves=[],streaks=[];
function buildScene(){
px0=Wc*.40; treeX=Wc*.115; lampX=Wc*.76;
roofs=[]; let rx=-20;
while(rx<Wc+40){ const w=30+Math.random()*80, h=(10+Math.random()*30)*U;
roofs.push({x:rx,w,h,t:Math.random()<.3}); rx+=w+3; }
puddles=[{x:Wc*.28,y:Hc*.935,rx:78*U},{x:Wc*.60,y:Hc*.958,rx:96*U},{x:Wc*.90,y:Hc*.925,rx:58*U}];
P.feet.u.x=px0-14*U; P.feet.u.y=fy; P.feet.d.x=px0+14*U; P.feet.d.y=fy;
P.step=null; P.skid=0; P.hipX=px0;
leaves=[]; for(let i=0;i<7;i++){
const gy=hy+30*U+Math.random()*Math.max(10,(Hc-hy-42*U));
leaves.push({x:Math.random()*Wc,y:gy-Math.random()*40*U,vx:0,vy:0,
rot:Math.random()*TAU,seed:Math.random(),a:0,gy}); }
streaks=[]; for(let i=0;i<16;i++)streaks.push({x:Math.random()*Wc,y:Math.random()*hy*.92,
ph:Math.random()*TAU,m:.7+Math.random()*.6,len:60+Math.random()*120});
}
function resize(){
dpr=Math.min(2,window.devicePixelRatio||1); Wc=innerWidth; Hc=innerHeight;
cv.width=Wc*dpr; cv.height=Hc*dpr;
U=clamp(Math.min(Hc/620,Wc/980),.55,1.45);
hy=Hc*.70; fy=Hc*.885; buildScene();
}
addEventListener('resize',resize);
/* ---------- grain texture ---------- */
const grain=(()=>{ const c=document.createElement('canvas'); c.width=c.height=160;
const q=c.getContext('2d'), im=q.createImageData(160,160);
for(let i=0;i<im.data.length;i+=4){ const v=Math.random()*255|0;
im.data[i]=im.data[i+1]=im.data[i+2]=v; im.data[i+3]=22; }
q.putImageData(im,0,0); return g.createPattern(c,'repeat'); })();
/* ---------- wind ---------- */
const wind={target:4,cur:4,g:0,burst:0,eff:4,prevEff:4,auto:true,autoT:0};
function upWind(dt){
if(wind.auto){ wind.autoT+=dt; wind.target=2+30*(1-Math.exp(-wind.autoT/40)); }
wind.cur+=(wind.target-wind.cur)*(1-Math.exp(-dt/1.3));
const s=.30*Math.min(1,wind.cur/10); // gusts only matter once it blows
wind.g+=(-wind.g/2.2)*dt+s*Math.sqrt(dt)*gauss();
wind.g=clamp(wind.g,-.55,.6);
wind.burst*=Math.exp(-dt/.7);
wind.eff=Math.max(0,wind.cur*(1+wind.g*.38*Math.min(1,wind.cur/12))+wind.burst);
const d=wind.eff-wind.prevEff; wind.prevEff=wind.eff;
if(d>1.8)jolt(d);
}
function jolt(d){ // a sharp rise shoves everything
P.skidV+=d*1.6*U; P.leanV-=d*1.4;
umb.off.vx+=d*2.4*U; umb.thV+=d*1.6; umb.bendV+=d*3.2; umb.gV+=d*3.5;
shake+=d*.3;
if(P.overload>.5&&umb.state==='held'&&!P.step&&Math.random()<.5)
startStep((26+92*clamp((wind.eff-6)/22,0,1))*U);
}
/* ---------- person ---------- */
const P={lean:2,leanV:0,skid:0,skidV:0,crouch:0,overload:0,handRaise:0,hipX:0,
feet:{u:{x:0,y:0},d:{x:0,y:0}},step:null,sk:null};
const trem=tt=>(Math.sin(tt*11)*1.1+Math.sin(tt*23+1.7)*.7)*P.overload;
function startStep(stT){ if(P.step)return;
P.step={f:'d',t:0,x0:P.feet.d.x,x1:P.hipX+stT/2}; }
function upPerson(dt){
const F=umb.F, eff=wind.eff;
P.overload=clamp((F-470)/300,0,1.5);
const leanT=Math.min(42,F/720*40+eff*.12); // lean into the wind
P.leanV+=((leanT-P.lean)*30-P.leanV*7)*dt; P.lean+=P.leanV*dt;
const skT=Math.min(14,P.overload*14)*U; // footing gives a little
P.skidV+=((skT-P.skid)*46-P.skidV*7)*dt; P.skid+=P.skidV*dt;
P.crouch+=(clamp((eff-13)/18,0,1)*.55-P.crouch)*(1-Math.exp(-dt/.6));
const stT=(26+92*clamp((eff-6)/22,0,1))*U; // brace wider as it builds
const stNow=P.feet.d.x-P.feet.u.x;
if(!P.step&&Math.abs(stT-stNow)>12*U)startStep(stT);
if(P.step){ const s=P.step; s.t+=dt/.34; const p=Math.min(1,s.t), e=p*p*(3-2*p);
const f=P.feet[s.f]; f.x=s.x0+(s.x1-s.x0)*e; f.y=fy-Math.sin(Math.PI*p)*9*U;
if(p>=1){ f.y=fy; P.step=null; } }
P.handRaise=Math.max(0,P.handRaise-dt*1.4);
P.hipX=px0+P.skid;
const st=P.feet.d.x-P.feet.u.x, reach=66*U;
const hipY=fy-Math.sqrt(Math.max(120*U*U,reach*reach-(st*.5)*(st*.5)))*(1-P.crouch*.10);
const td=norm(-Math.sin(P.lean*RAD),-Math.cos(P.lean*RAD));
P.sk={hipX:P.hipX,hipY,shX:P.hipX+td.x*46*U,shY:hipY+td.y*46*U,td};
}
/* ---------- umbrella ---------- */
const umb={state:'held',inverted:false,phiU:55,phiD:55,phiUv:0,phiDv:0,
th:6,thV:0,bend:0,bendV:0,g:0,gV:0,off:{x:0,y:0,vx:0,vy:0},
inv:0,invSnap:0,snapT:9,dImp:true,F:0,geo:null,cp:null,
fx:0,fy2:0,fvx:0,fvy:0,fang:0,fw:0,rg:{x:0,y:0},rc:{x:0,y:0},respawnT:0,openT:0,popped:false};
function umbGeometry(){
const sk=P.sk, L=88*U;
const G={x:sk.shX-5*U+umb.off.x, y:sk.shY-28*U+umb.off.y};
const thR=umb.th*RAD, bR=umb.bend*RAD;
const d1={x:-Math.sin(thR),y:-Math.cos(thR)};
const d2={x:-Math.sin(thR-bR),y:-Math.cos(thR-bR)};
const Cm={x:G.x+d1.x*L*.55,y:G.y+d1.y*L*.55};
const C={x:Cm.x+d2.x*L*.45,y:Cm.y+d2.y*L*.45};
return {G,C,d1};
}
function canopyPts(G,C,flut){
const R=62*U, gR=umb.g*RAD, cs=Math.cos(gR), sn=Math.sin(gR);
const eff=wind.eff, load=clamp((eff-8)/9,0,1), wf=clamp(eff/30,0,1);
const drape={x:(4+16*umb.inv+9*wf)*U,y:(3+10*umb.inv+6*wf)*U};
const sagU=(5-26*load)*U, sagD=(5+12*load)*U;
const pts=[], ribs=[];
for(const s of [-1,1]){
const phi=(s<0?umb.phiU:umb.phiD)*RAD;
const dir={x:s*Math.sin(phi),y:Math.cos(phi)};
const m={x:-s*Math.cos(phi),y:Math.sin(phi)};
const sag=s<0?sagU:sagD, K=5, arr=[];
for(let k=0;k<=K;k++){ const f=k/K;
const fl=flut?Math.sin(t*16+f*7+s*3)*(.5+3.5*wf)*U:0;
const sg=(sag+fl)*Math.sin(Math.PI*f);
let p={x:C.x+dir.x*R*f+m.x*sg+drape.x*Math.sin(Math.PI*f),
y:C.y+dir.y*R*f+m.y*sg+drape.y*Math.sin(Math.PI*f)};
const dx=p.x-C.x, dy=p.y-C.y; // canopy weathervanes about the crown
p={x:C.x+dx*cs-dy*sn, y:C.y+dx*sn+dy*cs};
arr.push(p);
}
ribs.push({rim:arr[K],dir:{x:dir.x*cs-dir.y*sn,y:dir.x*sn+dir.y*cs}});
if(s<0){ for(let k=K;k>=1;k--)pts.push(arr[k]); }
else { for(let k=1;k<=K;k++)pts.push(arr[k]); }
}
const poly=[ribs[0].rim,...pts,ribs[1].rim];
return {pts,ribs,poly};
}
function invert(){
umb.inverted=true; umb.snapT=0; umb.dImp=false;
umb.phiUv+=250; umb.gV+=150; umb.bendV+=130; P.leanV-=40; shake+=3;
sfx('pop'); burstDroplets(10);
}
function release(){
umb.state='flying';
const geo=umb.geo;
umb.fx=(geo.G.x+geo.C.x)/2; umb.fy2=(geo.G.y+geo.C.y)/2+8*U;
umb.rg={x:geo.G.x-umb.fx,y:geo.G.y-umb.fy2};
umb.rc={x:geo.C.x-umb.fx,y:geo.C.y-umb.fy2};
umb.fvx=wind.eff*11*U*.5+50*U; umb.fvy=-(150+Math.random()*90)*U;
umb.fang=0; umb.fw=4+Math.random()*4;
P.leanV-=120; P.handRaise=1; shake+=3; sfx('whip');
}
function respawn(){
umb.state='opening'; umb.openT=0; umb.popped=false; umb.inverted=false;
umb.inv=0; umb.invSnap=0; umb.phiU=8; umb.phiD=8; umb.phiUv=0; umb.phiDv=0;
umb.bend=0; umb.bendV=0; umb.g=0; umb.gV=0; umb.off.x=umb.off.vx=0; umb.off.y=umb.off.vy=0;
ui.st.classList.remove('bad');
}
function upFly(dt){
const Wpx=wind.eff*11*U, wf=clamp(wind.eff/30,0,1);
umb.fvx+=(Wpx*1.06-umb.fvx)*(1-Math.exp(-dt/.5)); // light frame obeys the air
umb.fvy+=320*U*dt; umb.fvy-=umb.fvy*1.5*dt;
umb.fw+=((Wpx-umb.fvx)*.012-umb.fw*.30)*dt;
umb.fx+=umb.fvx*dt; umb.fy2+=umb.fvy*dt; umb.fang+=umb.fw*dt;
const gy=fy-30*U;
if(umb.fy2>gy){ umb.fy2=gy;
if(umb.fvy>70*U){ umb.fvy*=-.42; umb.fw=umb.fw*.7+(Math.random()-.3)*3; }
else{ umb.fvy=0; umb.fw=umb.fvx/(30*U);
umb.fvx+=(Wpx*.92-umb.fvx)*(1-Math.exp(-dt/.7)); } }
const bu=umb.inverted?138:55, bd=umb.inverted?128:45;
umb.phiU=bu+Math.sin(t*9)*4*wf; umb.phiD=bd+Math.sin(t*7+2)*4*wf;
umb.inv=clamp((umb.phiU-55)/83,0,1);
if(umb.fx>Wc+180*U){ umb.state='gone'; umb.respawnT=1.6+Math.random()*1.2; }
}
function upUmb(dt){
const eff=wind.eff;
if(umb.state==='flying'){ umb.F=0; upFly(dt); return; }
if(umb.state==='gone'){ umb.F=0; umb.respawnT-=dt; if(umb.respawnT<=0)respawn(); return; }
const load=clamp((eff-8)/9,0,1);
const areaF=Math.pow(clamp(((umb.phiU+umb.phiD)/2)/55,0,1.3),2);
const tiltF=clamp(umb.th/55,0,1);
umb.F=eff*eff*(1-.25*tiltF)*areaF*(1+.62*umb.invSnap);
if(umb.state==='opening'){ umb.openT+=dt;
if(!umb.popped){ umb.phiU+=(8-umb.phiU)*(1-Math.exp(-dt/.12)); umb.phiD=umb.phiU; }
if(!umb.popped&&umb.openT>.4){ umb.popped=true; umb.state='held';
umb.phiUv=170; umb.phiDv=150; umb.gV-=120; umb.bendV+=90;
shake+=2; sfx('open'); burstDroplets(8); } }
const thT=Math.min(55,eff*2.7); // wrists tilt it into the wind
umb.thV+=((thT-umb.th)*20-umb.thV*6)*dt; umb.th+=umb.thV*dt;
const bT=42*clamp(umb.F/720,0,1.15); // the shaft loads up and bends
umb.bendV+=((bT-umb.bend)*90-umb.bendV*7)*dt; umb.bend+=umb.bendV*dt;
const gT=5+22*load*(1-umb.inv*.5)+6*umb.inv;
umb.gV+=((gT-umb.g)*60-umb.gV*6)*dt; umb.g+=umb.gV*dt;
const oT=clamp(umb.F/720,0,1.1)*22*U; // arms stretch under the pull
umb.off.vx+=((oT-umb.off.x)*150-umb.off.vx*12)*dt; umb.off.x+=umb.off.vx*dt;
umb.off.vy+=((0-umb.off.y)*150-umb.off.vy*12)*dt; umb.off.y+=umb.off.vy*dt;
if(umb.state==='held'){
let puT,pdT;
if(umb.inverted){ puT=138; pdT=128; }
else{
puT=55+45*Math.pow(load,1.25);
if(eff<=16.8)puT=Math.min(puT,92); // below a gale the ribs hold
else puT=Math.min(118,92+(eff-16.8)*9);
pdT=55-14*load;
}
umb.phiUv+=((puT-umb.phiU)*380-umb.phiUv*13)*dt; umb.phiU+=umb.phiUv*dt;
umb.phiDv+=((pdT-umb.phiD)*380-umb.phiDv*13)*dt; umb.phiD+=umb.phiDv*dt;
umb.snapT+=dt;
if(umb.inverted&&!umb.dImp&&umb.snapT>.09){ umb.dImp=true; umb.phiDv+=210; }
if(!umb.inverted&&eff>16.8&&umb.phiU>96)invert(); // past the flip angle, in a gale
}
umb.inv=clamp((umb.phiU-55)/83,0,1);
umb.invSnap=umb.inverted?clamp(umb.snapT/.45,0,1):0;
if(umb.state==='held'&&umb.F>720)release(); // the grip loses the fight
}
/* ---------- rain, spray, ripples ---------- */
const drops=[], spray=[], ripples=[];
function newDrop(anyY){
const z=Math.random()<.45?0:1;
const sl=wind.eff*11/430;
return {x:Math.random()*(Wc*1.5+sl*Hc)-Wc*.25-sl*Hc*.8,
y:anyY?Math.random()*Hc:-30-Math.random()*100,
sp:(.75+Math.random()*.5)*(z?1:.72), dv:.9+Math.random()*.3, z,
land:z?Hc-4:hy+(fy-hy)*.5};
}
function seedRain(){ drops.length=0;
const n=Math.round(clamp(Wc*Hc/8500,90,230));
for(let i=0;i<n;i++)drops.push(newDrop(true)); }
const inPuddle=(x,y)=>puddles.some(p=>((x-p.x)/p.rx)**2+((y-p.y)/(p.rx*.22))**2<1);
function ripple(x,y,z){ if(ripples.length>70)ripples.shift();
ripples.push({x,y,r:1+Math.random()*2,a:z?.4:.2,pu:inPuddle(x,y)}); }
function burstDroplets(n){ const cp=umb.cp; if(!cp)return;
for(let i=0;i<n;i++){ const p=cp.pts[(Math.random()*cp.pts.length)|0];
spray.push({x:p.x,y:p.y,vx:(60+Math.random()*160)*U+wind.eff*3*U,
vy:-(40+Math.random()*120)*U,life:.5+Math.random()*.3}); }
if(spray.length>90)spray.splice(0,spray.length-90);
}
function segseg(p1x,p1y,p2x,p2y,p3x,p3y,p4x,p4y){
const d1x=p2x-p1x,d1y=p2y-p1y,d2x=p4x-p3x,d2y=p4y-p3y;
const den=d1x*d2y-d1y*d2x; if(Math.abs(den)<1e-9)return null;
const tt=((p3x-p1x)*d2y-(p3y-p1y)*d2x)/den;
const uu=((p3x-p1x)*d1y-(p3y-p1y)*d1x)/den;
return (tt>=0&&tt<=1&&uu>=0&&uu<=1)?tt:null;
}
function upRain(dt){
const kp=11*U, eff=wind.eff, cp=umb.cp;
let bb=null;
if(cp&&umb.state==='held'){
let x0=1e9,x1=-1e9,y0=1e9,y1=-1e9;
for(const p of cp.poly){ x0=Math.min(x0,p.x); x1=Math.max(x1,p.x);
y0=Math.min(y0,p.y); y1=Math.max(y1,p.y); }
bb={x0:x0-20,x1:x1+20,y0:y0-20,y1:y1+20};
}
for(const d of drops){
const vx=eff*kp*d.dv*(d.z?1:.82), vy=430*U*d.sp;
const px=d.x, py=d.y;
d.x+=vx*dt; d.y+=vy*dt;
if(bb&&d.z===1&&d.x>bb.x0&&d.x<bb.x1&&d.y>bb.y0&&d.y<bb.y1){
let hit=-1,ht=0;
for(let i=0;i<cp.poly.length-1;i++){
const h=segseg(px,py,d.x,d.y,cp.poly[i].x,cp.poly[i].y,cp.poly[i+1].x,cp.poly[i+1].y);
if(h!==null){ hit=i; ht=h; break; } }
if(hit>=0){
const s=cp.poly[hit], e=cp.poly[hit+1];
let tn=norm(e.x-s.x,e.y-s.y); if(tn.x<0)tn={x:-tn.x,y:-tn.y};
for(let k=0;k<2;k++)spray.push({
x:px+(d.x-px)*ht, y:py+(d.y-py)*ht,
vx:tn.x*(80+Math.random()*120)*U+eff*4*U,
vy:tn.y*60*U-(30+Math.random()*80)*U, life:.4+Math.random()*.25});
Object.assign(d,newDrop(false)); continue;
}
}
if(d.y>d.land){ ripple(d.x,d.land,d.z); Object.assign(d,newDrop(false)); }
else if(d.x>Wc+80||d.x<-Wc*.9)Object.assign(d,newDrop(false));
}
}
function upFx(dt){
for(const r of ripples){ r.r+=34*U*dt; r.a-=dt*1.1; }
for(let i=ripples.length-1;i>=0;i--)if(ripples[i].a<=0)ripples.splice(i,1);
for(const s of spray){ s.vy+=760*U*dt; s.x+=s.vx*dt; s.y+=s.vy*dt; s.life-=dt; }
for(let i=spray.length-1;i>=0;i--)if(spray[i].life<=0)spray.splice(i,1);
for(const s of streaks){ s.x+=wind.eff*11*U*s.m*dt;
s.y+=Math.sin(t*1.7+s.ph)*6*U*dt;
if(s.x>Wc+60){ s.x=-60-Math.random()*160; s.y=Math.random()*hy*.9; } }
for(const l of leaves){
l.a+=((wind.eff>7.5?1:0)-l.a)*(1-Math.exp(-dt/1.2));
l.vx+=(wind.eff*11*U*(.45+l.seed*.55)-l.vx)*(1-Math.exp(-dt/.6));
l.vy+=430*U*dt; l.vy-=l.vy*2.4*dt;
l.x+=l.vx*dt; l.y+=l.vy*dt;
if(l.y>=l.gy){ l.y=l.gy; l.vy=0;
if(Math.random()<dt*(.4+wind.eff*.22)){ l.vy=-(50+Math.random()*160)*U; l.vx+=40*U; } }
l.rot+=(.5+l.seed*1.2)*(.15+wind.eff*.05)*dt*(l.vy<0?2.2:1);
if(l.x>Wc+50){ l.x=-40-Math.random()*120;
l.gy=hy+30*U+Math.random()*Math.max(10,(Hc-hy-42*U)); l.y=l.gy-20*U; l.vx=0; } }
if(wind.eff>18)for(const l of leaves) // the tree sheds in a storm
if(l.a<.05&&Math.random()<dt*wind.eff*.02){
l.x=treeX+(Math.random()*40-20)*U; l.y=hy-95*U; l.a=1; l.vy=0; break; }
}
/* ---------- sound (all synthesized, starts on first interaction) ---------- */
let AC=null,noiseBuf=null,wGain=null,wFilt=null,rGain=null,sndOn=true;
function startAudio(){ if(AC)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
const len=2*AC.sampleRate;
noiseBuf=AC.createBuffer(1,len,AC.sampleRate);
const d=noiseBuf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
const mk=()=>{ const s=AC.createBufferSource(); s.buffer=noiseBuf; s.loop=true; s.start(); return s; };
wFilt=AC.createBiquadFilter(); wFilt.type='lowpass'; wFilt.frequency.value=200; wFilt.Q.value=.7;
wGain=AC.createGain(); wGain.gain.value=0;
mk().connect(wFilt); wFilt.connect(wGain); wGain.connect(AC.destination);
const hp=AC.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=2600;
rGain=AC.createGain(); rGain.gain.value=0;
mk().connect(hp); hp.connect(rGain); rGain.connect(AC.destination);
}catch(e){ AC=null; }
}
function upAudio(){ if(!AC)return;
const t0=AC.currentTime, w=Math.pow(clamp(wind.eff/35,0,1.2),1.5)*.5*(1+Math.max(0,wind.g)*1.3);
wGain.gain.setTargetAtTime(sndOn?Math.min(.75,w):0,t0,.2);
wFilt.frequency.setTargetAtTime(120+wind.eff*26,t0,.25);
rGain.gain.setTargetAtTime(sndOn?(.02+clamp(wind.eff/40,0,1)*.09):0,t0,.3);
}
function sfx(kind){ if(!AC||!sndOn||!noiseBuf)return;
try{
const t0=AC.currentTime, s=AC.createBufferSource(); s.buffer=noiseBuf;
const f=AC.createBiquadFilter(), gn=AC.createGain(); let dur=.25;
if(kind==='pop'){ f.type='bandpass'; f.frequency.value=210; f.Q.value=1.1;
gn.gain.setValueAtTime(.55,t0); gn.gain.exponentialRampToValueAtTime(.001,t0+.22); dur=.24; }
else if(kind==='whip'){ f.type='bandpass'; f.Q.value=1.4;
f.frequency.setValueAtTime(950,t0); f.frequency.exponentialRampToValueAtTime(160,t0+.28);
gn.gain.setValueAtTime(.4,t0); gn.gain.exponentialRampToValueAtTime(.001,t0+.3); dur=.32; }
else{ f.type='bandpass'; f.frequency.value=520; f.Q.value=1.5;
gn.gain.setValueAtTime(.3,t0); gn.gain.exponentialRampToValueAtTime(.001,t0+.14); dur=.16; }
s.connect(f); f.connect(gn); gn.connect(AC.destination); s.start(t0); s.stop(t0+dur);
}catch(e){}
}
/* ---------- drawing ---------- */
let t=0, shake=0, joltFlash=0, skyF=0;
function smoothPoly(p){
g.moveTo(p[0].x,p[0].y);
for(let i=1;i<p.length-1;i++)
g.quadraticCurveTo(p[i].x,p[i].y,(p[i].x+p[i+1].x)/2,(p[i].y+p[i+1].y)/2);
g.lineTo(p[p.length-1].x,p[p.length-1].y);
}
function drawUmb(G,C,d1,cp,bend){
const inv=umb.inv, wf=clamp(wind.eff/30,0,1);
const mx=(G.x+C.x)/2, my=(G.y+C.y)/2, perp={x:-d1.y,y:d1.x}, bow=bend*1.1*U;
g.lineCap='round';
g.strokeStyle='#463d33'; g.lineWidth=3.4*U; // shaft, bowed by the load
g.beginPath(); g.moveTo(G.x,G.y);
g.quadraticCurveTo(mx+perp.x*bow,my+perp.y*bow,C.x,C.y); g.stroke();
const sd=norm(C.x-G.x,C.y-G.y);
g.strokeStyle='#5a544c'; g.lineWidth=2.4*U;
g.beginPath(); g.moveTo(C.x,C.y); g.lineTo(C.x+sd.x*9*U,C.y+sd.y*9*U); g.stroke();
g.strokeStyle='#463d33'; g.lineWidth=4.2*U; // crooked handle
g.beginPath(); g.moveTo(G.x,G.y);
g.quadraticCurveTo(G.x+.5*U,G.y+11*U,G.x+9*U,G.y+10*U); g.stroke();
const rU=cp.ribs[0].rim, rD=cp.ribs[1].rim;
const sagB=(4+9*wf+30*inv)*U, drX=(6+20*inv)*U;
g.beginPath(); smoothPoly([rU,...cp.pts,rD]);
g.quadraticCurveTo((rU.x+rD.x)/2+drX,(rU.y+rD.y)/2+sagB,rU.x,rU.y);
g.closePath();
g.fillStyle=rgb(mix([191,74,48],[116,40,29],inv)); g.fill();
g.strokeStyle='rgba(58,18,12,.6)'; g.lineWidth=1.1*U; g.stroke();
g.strokeStyle=`rgba(58,18,12,${.34-.16*inv})`; g.lineWidth=1*U;
for(const u of [.36,.64]){ const px2=rU.x+(rD.x-rU.x)*u, py2=rU.y+(rD.y-rU.y)*u;
g.beginPath(); g.moveTo(C.x,C.y);
g.quadraticCurveTo((C.x+px2)/2+drX*.35,(C.y+py2)/2+sagB*.3,px2,py2); g.stroke(); }
g.strokeStyle='rgba(48,15,10,.75)'; g.lineWidth=1.5*U;
g.beginPath(); g.moveTo(C.x,C.y); g.lineTo(rU.x,rU.y);
g.moveTo(C.x,C.y); g.lineTo(rD.x,rD.y); g.stroke();
g.strokeStyle='#463d33'; g.lineWidth=1.8*U; g.fillStyle='#5a544c';
for(const r of cp.ribs){ const ex=r.rim.x+r.dir.x*8*U, ey=r.rim.y+r.dir.y*8*U;
g.beginPath(); g.moveTo(r.rim.x,r.rim.y); g.lineTo(ex,ey); g.stroke();
g.beginPath(); g.arc(ex,ey,1.7*U,0,TAU); g.fill(); }
}
function drawUmbrellaMain(){
if(umb.state==='flying'){
g.save(); g.translate(umb.fx,umb.fy2); g.rotate(umb.fang);
const d1=norm(umb.rc.x-umb.rg.x,umb.rc.y-umb.rg.y);
drawUmb(umb.rg,umb.rc,d1,canopyPts(umb.rg,umb.rc,true),umb.bend);
g.restore();
}else if(umb.state!=='gone'&&umb.geo){
drawUmb(umb.geo.G,umb.geo.C,umb.geo.d1,umb.cp,umb.bend);
}
}
function limb(x0,y0,x1,y1,l1,l2,col,w,kind){
const dx=x1-x0, dy=y1-y0, d=Math.hypot(dx,dy)||.001;
let nx,ny;
if(kind==='leg'){ nx=dy/d; ny=-dx/d; if(nx>0){ nx=-dy/d; ny=dx/d; } }
else{ nx=-dy/d; ny=dx/d; if(ny<0){ nx=dy/d; ny=-dx/d; } }
const h=Math.sqrt(Math.max(0,l1*l1-d*d/4));
g.strokeStyle=col; g.lineWidth=w; g.lineCap='round'; g.lineJoin='round';
g.beginPath(); g.moveTo(x0,y0);
g.lineTo(x0+dx/2+nx*h,y0+dy/2+ny*h); g.lineTo(x1,y1); g.stroke();
}
function drawPerson(){
const sk=P.sk, wf=clamp(wind.eff/30,0,1);
const tr=(trem(t)+Math.sin(t*.8)*.5)*RAD;
const td={x:-Math.sin(P.lean*RAD+tr),y:-Math.cos(P.lean*RAD+tr)};
const hipX=sk.hipX, hipY=sk.hipY;
const shX=hipX+td.x*46*U, shY=hipY+td.y*46*U;
let h1,h2;
if((umb.state==='held'||umb.state==='opening')&&umb.geo){
const G=umb.geo.G, sd=norm(umb.geo.C.x-G.x,umb.geo.C.y-G.y);
h1={x:G.x+sd.x*6*U,y:G.y+sd.y*6*U}; h2={x:G.x+sd.x*17*U,y:G.y+sd.y*17*U};
}else{
const e=P.handRaise*P.handRaise*(3-2*P.handRaise);
h1={x:lerp(hipX-12*U,shX-30*U,e),y:lerp(hipY-18*U,shY-26*U,e)};
h2={x:lerp(hipX-2*U,shX-4*U,e),y:lerp(hipY-20*U,shY-40*U,e)};
}
const perpD={x:-td.y,y:td.x};
const shN={x:shX+perpD.x*3*U,y:shY+perpD.y*3*U};
const shF={x:shX-perpD.x*3*U,y:shY-perpD.y*3*U};
limb(hipX,hipY,P.feet.u.x,P.feet.u.y,34*U,34*U,'#191c23',7*U,'leg');
limb(hipX,hipY,P.feet.d.x,P.feet.d.y,34*U,34*U,'#22262e',7*U,'leg');
for(const f of [P.feet.u,P.feet.d]){
g.strokeStyle='#14161b'; g.lineWidth=5*U; g.lineCap='round';
g.beginPath(); g.moveTo(f.x+2*U,f.y-2.5*U); g.lineTo(f.x-9*U,f.y-2.5*U); g.stroke(); }
limb(shF.x,shF.y,h1.x,h1.y,24*U,24*U,'#1c2027',6.5*U,'arm'); // far arm
g.strokeStyle='#262a33'; g.lineWidth=15*U; g.lineCap='round'; // torso
g.beginPath(); g.moveTo(hipX,hipY+2*U); g.lineTo(shX,shY); g.stroke();
const fl2=Math.sin(t*9+1)*(1.5+6*wf)*U; // coat hem streams
g.fillStyle='#262a33';
g.beginPath(); g.moveTo(hipX-7*U,hipY+2*U); g.lineTo(hipX+7*U,hipY+3*U);
g.lineTo(hipX+(11+10*wf)*U+fl2,hipY+14*U); g.closePath(); g.fill();
g.beginPath(); g.moveTo(shX-3*U,shY+1*U); g.lineTo(shX+4*U,shY+2*U);
g.lineTo(shX+(8+8*wf)*U+fl2*.7,shY+9*U); g.closePath(); g.fill();
const hx=shX+td.x*13*U, hy2=shY+td.y*13*U; // hooded head
g.fillStyle='#262a33';
g.beginPath(); g.arc(hx,hy2,8.5*U,0,TAU); g.fill();
g.beginPath(); g.moveTo(hx-7*U,hy2-3*U);
g.quadraticCurveTo(hx-3*U,hy2-12*U,hx+5*U,hy2-6*U); g.lineTo(hx+2*U,hy2);
g.closePath(); g.fill();
g.strokeStyle=`rgba(205,215,225,${.05+.20*skyF})`; g.lineWidth=1.2*U;
g.beginPath(); g.arc(hx,hy2,8.5*U,Math.PI*.75,Math.PI*1.45); g.stroke();
g.beginPath(); g.moveTo(hipX-perpD.x*7*U,hipY-perpD.y*7*U);
g.lineTo(shX-perpD.x*6*U,shY-perpD.y*6*U); g.stroke();
drawUmbrellaMain();
limb(shN.x,shN.y,h2.x,h2.y,24*U,24*U,'#262a33',6.5*U,'arm'); // near arm
g.fillStyle='#262a33';
for(const h of [h1,h2]){ g.beginPath(); g.arc(h.x,h.y,3.4*U,0,TAU); g.fill(); }
}
function drawTree(){
const bx=treeX, by=hy+16*U, wf=clamp(wind.eff/30,0,1);
const bend=Math.min(66*U,wind.eff*2.5*U)*(.85+.15*Math.sin(t*6.3));
const tx=bx+bend, ty=by-118*U;
g.strokeStyle='#20261e'; g.lineCap='round'; g.lineWidth=6*U;
g.beginPath(); g.moveTo(bx,by); g.quadraticCurveTo(bx+bend*.2,by-62*U,tx,ty); g.stroke();
g.fillStyle='#242b21';
for(let i=0;i<3;i++){
const jx=Math.sin(t*12+i*2.1)*(1+5*wf)*U, jy=Math.cos(t*10+i*1.7)*(1+4*wf)*U;
g.beginPath();
g.ellipse(tx+(i-1)*17*U+jx,ty-6*U+i*7*U+jy,(24-i*4)*U,(15-i*2.4)*U,0,0,TAU);
g.fill(); }
}
function drawLamp(){
const lx=lampX, lb0=fy-4*U, lh=182*U, lb=wind.eff*.45*U;
const tx=lx+lb, ty=lb0-lh;
g.strokeStyle='#171a1f'; g.lineCap='round'; g.lineWidth=3.4*U;
g.beginPath(); g.moveTo(lx,lb0); g.quadraticCurveTo(lx+lb*.35,lb0-lh*.55,tx,ty); g.stroke();
g.lineWidth=2.2*U;
g.beginPath(); g.moveTo(tx-8*U,ty+3*U); g.lineTo(tx+8*U,ty+3*U); g.stroke();
let la=.25+.75*skyF;
if(wind.eff>24)la*=.78+.22*Math.sin(t*31+Math.sin(t*7)*9);
g.fillStyle=`rgba(233,178,106,${clamp(la,0,1)})`;
g.beginPath(); g.arc(tx,ty+9*U,5.2*U,0,TAU); g.fill();
g.fillStyle=`rgba(233,178,106,${.10*skyF})`;
g.beginPath(); g.ellipse(tx,fy+2*U,46*U,7*U,0,0,TAU); g.fill();
}
function draw(){
g.setTransform(dpr,0,0,dpr,0,0);
const shk=RM?0:shake+Math.max(0,wind.eff-22)*.05;
if(shk>.05)g.translate(gauss()*shk,gauss()*shk*.6);
const fp=Math.pow(skyF,.85);
g.fillStyle=rgb(mix([122,135,146],[21,25,32],fp)); g.fillRect(-8,-8,Wc+16,hy+8);
g.fillStyle=rgb(mix(mix([122,135,146],[21,25,32],fp),[13,16,21],.45));
for(const r of roofs){ g.fillRect(r.x,hy-r.h,r.w,r.h+2);
if(r.t)g.fillRect(r.x+r.w*.3,hy-r.h-7*U,3*U,7*U); }
g.fillStyle=rgb(mix([74,79,86],[18,21,26],skyF)); g.fillRect(-8,hy,Wc+16,Hc-hy+8);
g.fillStyle='rgba(255,255,255,.03)';
g.fillRect(-8,hy+18*U,Wc+16,1); g.fillRect(-8,fy+14*U,Wc+16,1);
for(const p of puddles){
g.fillStyle=rgb(mix(mix([74,79,86],[18,21,26],skyF),mix([122,135,146],[21,25,32],fp),.45));
g.beginPath(); g.ellipse(p.x,p.y,p.rx,p.rx*.2,0,0,TAU); g.fill();
g.strokeStyle='rgba(220,230,238,.10)'; g.lineWidth=1;
g.beginPath(); g.ellipse(p.x,p.y,p.rx*.7,p.rx*.13,0,0,TAU); g.stroke(); }
drawLamp(); drawTree();
for(const r of ripples){
g.strokeStyle=`rgba(205,222,232,${Math.min(.5,r.a*(r.pu?1.8:1))})`; g.lineWidth=1;
g.beginPath(); g.ellipse(r.x,r.y,r.r,r.r*.3,0,0,TAU); g.stroke(); }
for(const l of leaves){ if(l.a<.02)continue;
g.save(); g.translate(l.x,l.y); g.rotate(l.rot); g.globalAlpha=Math.min(1,l.a)*.9;
g.fillStyle='#232a21'; g.beginPath(); g.ellipse(0,0,6.5*U,2.6*U,0,0,TAU); g.fill();
g.strokeStyle='rgba(10,14,10,.5)'; g.lineWidth=.8*U;
g.beginPath(); g.moveTo(-6*U,0); g.lineTo(6*U,0); g.stroke(); g.restore(); }
drawPerson();
const kp=11*U;
for(const z of [0,1]){ // rain, two depths
g.strokeStyle=z?`rgba(196,210,222,${.30+.12*skyF})`:'rgba(196,210,222,.14)';
g.lineWidth=z?1.3:.8; g.beginPath();
for(const d of drops){ if(d.z!==z)continue;
const vx=wind.eff*kp*d.dv*(z?1:.82), vy=430*U*d.sp;
const sp=Math.hypot(vx,vy), k=clamp(.05*sp,6,26)*U/sp;
g.moveTo(d.x,d.y); g.lineTo(d.x-vx*k,d.y-vy*k); }
g.stroke(); }
g.lineWidth=1;
for(const s of spray){
g.strokeStyle=`rgba(206,220,230,${clamp(s.life*1.6,0,.6)})`;
g.beginPath(); g.moveTo(s.x,s.y); g.lineTo(s.x-s.vx*.02,s.y-s.vy*.02); g.stroke(); }
const a0=clamp((wind.eff-3)/30,0,1)*.12+joltFlash*.4;
if(a0>.01){ g.lineWidth=1; g.strokeStyle=`rgba(214,226,234,${Math.min(.3,a0)})`;
for(const s of streaks){ const L=s.len*(.5+wind.eff/26)*U;
g.beginPath(); g.moveTo(s.x-L,s.y+Math.sin(s.ph)*3*U);
g.quadraticCurveTo(s.x-L*.5,s.y-3*U,s.x,s.y); g.stroke(); } }
g.globalAlpha=.05; g.fillStyle=grain; g.fillRect(-8,-8,Wc+16,Hc+16); g.globalAlpha=1;
}
/* ---------- HUD ---------- */
const BFT=[[.5,'calm'],[1.5,'light air'],[3.3,'light breeze'],[5.5,'gentle breeze'],
[8,'moderate breeze'],[10.8,'fresh breeze'],[13.9,'strong breeze'],[17.2,'near gale'],
[20.8,'gale'],[24.5,'strong gale'],[28.5,'storm'],[32.7,'violent storm'],[99,'hurricane']];
function beaufort(v){ for(let i=0;i<BFT.length;i++)if(v<BFT[i][0])return[i,BFT[i][1]];
return [12,'hurricane']; }
function statusText(){
if(umb.state==='flying'||umb.state==='gone')return['gone \u2014 gone with the wind',true];
if(umb.state==='opening')return['a fresh umbrella',false];
if(umb.inverted)return['inside out \u2014 hanging on',true];
const e=wind.eff;
if(e<6)return['an easy stroll',false];
if(e<11)return['holding steady',false];
if(e<15)return['two hands, shoulders hunched',false];
if(e<16.8)return['knuckles white',false];
return['hanging on',true];
}
let slDrag=false, fn=0;
function hud(){
ui.w.textContent=wind.eff.toFixed(1);
ui.kmh.textContent=Math.round(wind.eff*3.6)+' km/h';
const gg=wind.eff-wind.cur;
ui.gu.textContent=gg>1.5?('gusting to '+wind.eff.toFixed(1)):'\u00a0';
const [bn,bn2]=beaufort(wind.cur);
ui.bf.textContent='F'+bn+' \u2014 '+bn2;
ui.bf.classList.toggle('sev',bn>=8);
const [stx,bad]=statusText();
ui.st.textContent=stx; ui.st.classList.toggle('bad',bad);
if(wind.auto&&!slDrag)ui.sl.value=wind.cur;
}
/* ---------- input ---------- */
const keys=new Set();
function setManual(){ if(wind.auto){ wind.auto=false; ui.auto.classList.remove('on'); } }
function gust(){ wind.burst=Math.min(15,wind.burst+6+Math.random()*5); joltFlash=.15; }
function toggleAuto(){ wind.auto=!wind.auto; if(wind.auto)wind.autoT=0;
ui.auto.classList.toggle('on',wind.auto); }
function toggleSnd(){ sndOn=!sndOn; ui.sb.textContent='sound: '+(sndOn?'on':'off');
if(sndOn)startAudio(); }
ui.sl.addEventListener('input',()=>{ setManual(); wind.target=+ui.sl.value; });
ui.sl.addEventListener('pointerdown',()=>slDrag=true);
addEventListener('pointerup',()=>slDrag=false);
ui.auto.addEventListener('click',()=>{ startAudio(); toggleAuto(); });
ui.gb.addEventListener('click',()=>{ startAudio(); gust(); });
ui.rb.addEventListener('click',()=>{ startAudio(); respawn(); });
ui.sb.addEventListener('click',toggleSnd);
cv.addEventListener('pointerdown',()=>{ startAudio(); gust(); });
document.addEventListener('keydown',e=>{
startAudio();
if(e.key==='ArrowRight'||e.key==='ArrowUp'){ keys.add('r'); setManual(); e.preventDefault(); }
else if(e.key==='ArrowLeft'||e.key==='ArrowDown'){ keys.add('l'); setManual(); e.preventDefault(); }
else if(e.key===' '||e.key==='g'||e.key==='G'){ gust(); e.preventDefault(); }
else if(e.key==='r'||e.key==='R')respawn();
else if(e.key==='a'||e.key==='A')toggleAuto();
else if(e.key==='s'||e.key==='S')toggleSnd();
});
document.addEventListener('keyup',e=>{
if(e.key==='ArrowRight'||e.key==='ArrowUp')keys.delete('r');
if(e.key==='ArrowLeft'||e.key==='ArrowDown')keys.delete('l'); });
/* ---------- loop ---------- */
let last=0;
function frame(ts){
requestAnimationFrame(frame);
const dt=clamp((ts-last)/1000,.001,.033); last=ts; t+=dt;
if(keys.has('r'))wind.target=Math.min(35,wind.target+9*dt);
if(keys.has('l'))wind.target=Math.max(0,wind.target-9*dt);
upWind(dt); upPerson(dt); upUmb(dt);
umb.geo=umbGeometry(); umb.cp=canopyPts(umb.geo.G,umb.geo.C,true);
upRain(dt); upFx(dt); upAudio();
skyF=clamp(wind.cur/30,0,1);
shake*=Math.exp(-dt*4); joltFlash*=Math.exp(-dt*3);
draw();
if(!(fn++%6))hud();
}
resize(); seedRain();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Where it's right: everything scales as W², so the escalation curve feels like a real gale rather than a linear fader — calm is calm, then things go wrong fast. The brink behavior is the part I'm happiest with: below ~17 m/s the ribs physically cannot flip, so you get the true pre-failure pose — upwind rim lifted, canopy flattened and quivering on gusts — and the actual snap is decided by a gust, not a timer. Inversion has hysteresis (it stays wrecked when the wind drops, like a real one), drag jumps ~60% after inversion so the person staggers harder holding a dead canopy than a live one, and grip failure is a separate later threshold, so the natural arc is invert → struggle → lose it downwind. The rain couples to the same wind number (slant, streak length, spawn range), and canopy collision gives an emergent dry shadow that moves and flips with the canopy.
Where it's wrong — and you'll notice: