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.
Here's my take: a side-view storm piece with a hand-rolled Verlet canopy, a grip that can only take so much torque, and a person who leans, widens their stance, and braces while the world gets louder.
The canopy is two chains of Verlet nodes (the visible rib silhouettes, left and right of the shaft) pinned at the shaft tip, held by per-segment length constraints plus bend springs that pull each node toward a rest curvature — so the dome is a bistable elastic shell, not a rigid sprite. Wind is dynamic pressure q = ½ρv² applied per node along the local surface normal (signed, so either face can be loaded) plus a small "internal pressure" term that stands in for wind ramming under the upwind rim. Inversion is a discrete state change: when the smoothed wind exceeds a per-umbrella threshold (62–76 km/h, randomized each umbrella) or the downwind rim physically rises past the apex, the rest-curvature sign flips and the node rest shape is swapped for a rotated "inside-out" claw, which releases stored bend energy as the snap. The same per-node forces are summed into a torque about the hand; the grip is a damped torsional spring with a clamped strength — inside the clamp the person compensates (the umbrella leans back), at the clamp it gets wrenched sideways, and sustained saturation, over-rotation, or extreme wind detaches it into a free rigid body (tethered by a strap for half a second, then snapped), after which it tumbles downwind. The person is a procedural IK skeleton whose lean, crouch, stance width, second-hand position, and trembling are all driven by the measured load, and Space braces (raises grip strength while a stamina meter lasts).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GALE — an umbrella against the wind</title>
<style>
:root{--ink:#cfd8e0;--dim:#8b98a5;--gold:#e2a63b;--line:rgba(255,255,255,.09);--panel:rgba(13,17,23,.55)}
*{box-sizing:border-box}
html,body{margin:0;height:100%;overflow:hidden;background:#0d1117;color:var(--ink);
font-family:ui-monospace,'SF Mono','Cascadia Mono',Menlo,Consolas,monospace}
#c{position:fixed;inset:0;width:100%;height:100%;touch-action:none;cursor:ew-resize}
.panel{position:fixed;background:var(--panel);border:1px solid var(--line);border-radius:12px;
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);z-index:5;user-select:none}
#title{top:16px;left:50%;transform:translateX(-50%);padding:10px 24px 12px;text-align:center}
#title h1{margin:0;font:600 15px/1 Georgia,'Times New Roman',serif;letter-spacing:.6em;text-indent:.6em;color:#e8edf2}
#title p{margin:6px 0 0;font:italic 11px/1.5 Georgia,serif;color:var(--dim)}
#title .hint{font:10px ui-monospace,Menlo,monospace;font-style:normal;letter-spacing:.08em;color:#5f6b77;margin-top:4px}
#windbox{top:16px;left:16px;padding:12px 16px;min-width:176px}
.lbl{font-size:9px;font-weight:700;letter-spacing:.22em;color:var(--dim)}
#windNum{font-size:42px;font-weight:600;color:#e8edf2;line-height:1.05}
.unit{font-size:11px;color:var(--dim);margin-left:6px}
#beaufort{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--gold);margin-top:2px}
#spark{display:block;margin-top:8px}
#status{top:16px;right:16px;padding:11px 16px;display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.12em}
#dot{width:9px;height:9px;border-radius:50%;background:#9ab8a0;flex:none}
#controls{bottom:16px;left:50%;transform:translateX(-50%);padding:12px 18px;display:flex;align-items:center;
gap:16px;flex-wrap:wrap;justify-content:center;max-width:94vw}
.sliderBox{display:flex;align-items:center;gap:10px}
#windSlider{width:170px;accent-color:var(--gold);cursor:pointer}
.chk{display:flex;align-items:center;gap:6px;font-size:10px;letter-spacing:.08em;color:var(--dim);cursor:pointer}
.chk input{accent-color:var(--gold)}
button{position:relative;background:none;border:1px solid var(--line);border-radius:8px;color:var(--ink);
font:600 10px ui-monospace,Menlo,monospace;letter-spacing:.14em;padding:9px 13px;cursor:pointer;overflow:hidden}
button:hover{border-color:var(--gold);color:#fff}
button .key{color:#5f6b77;margin-left:7px;font-weight:400}
#braceBar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:#7fa3a6;pointer-events:none}
@media (max-width:640px){#title{display:none}#windbox{min-width:0;padding:10px 12px}#windNum{font-size:30px}}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="title" class="panel">
<h1>GALE</h1>
<p>an umbrella against the wind</p>
<p class="hint">drag or ← → to drive the wind · hold SPACE to brace · R for a new umbrella</p>
</div>
<div id="windbox" class="panel">
<div class="lbl">WIND</div>
<div><span id="windNum">0</span><span class="unit">km/h</span></div>
<div id="beaufort">calm</div>
<svg id="spark" width="170" height="30" viewBox="0 0 170 30">
<line x1="0" y1="28.5" x2="170" y2="28.5" stroke="rgba(255,255,255,.14)"/>
<polyline id="sparkLine" fill="none" stroke="#e2a63b" stroke-width="1.5" points=""/>
</svg>
</div>
<div id="status" class="panel"><span id="dot"></span><span id="statusText">OPENING</span></div>
<div id="controls" class="panel">
<div class="sliderBox">
<span class="lbl">WIND</span>
<input id="windSlider" type="range" min="0" max="130" step="1" value="6">
<label class="chk"><input id="autoRise" type="checkbox" checked>LET IT BUILD</label>
</div>
<button id="braceBtn">BRACE<span class="key">SPACE</span><span id="braceBar"></span></button>
<button id="resetBtn">NEW UMBRELLA<span class="key">R</span></button>
<button id="soundBtn">SOUND OFF<span class="key">M</span></button>
</div>
<script>
(()=>{'use strict';
/* ---------------- canvas & camera ---------------- */
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
let W=0,H=0,DPR=1,S=100,OX=0,OY=0,wl=0,wr=0;
let treeBumps=[];
function genTrees(){treeBumps=[];let x=wl-2;while(x<wr+2){treeBumps.push({x:x,h:0.35+Math.random()*0.5});x+=0.35+Math.random()*0.5;}}
function resize(){DPR=Math.min(window.devicePixelRatio||1,2);W=window.innerWidth;H=window.innerHeight;
cv.width=Math.round(W*DPR);cv.height=Math.round(H*DPR);
S=Math.min(H/7.6,W/7);OX=W*0.32+1.6*S;OY=H*0.84;
wl=-OX/S;wr=(W-OX)/S;genTrees();}
window.addEventListener('resize',resize);
/* ---------------- utils ---------------- */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const TAU=Math.PI*2;
function rotV(v,c,s){return{x:c*v.x+s*v.y,y:-s*v.x+c*v.y};} // local->world, phi>0 tilts downwind
function mixColor(a,b,t){t=clamp(t,0,1);return[Math.round(lerp(a[0],b[0],t)),Math.round(lerp(a[1],b[1],t)),Math.round(lerp(a[2],b[2],t))];}
/* ---------------- audio (all synthesized, no assets) ---------------- */
let AC=null,soundOn=false,soundStarted=false,windGain=null,windFilt=null,rainGain=null,master=null;
function initAudio(){
if(AC)return;
try{
AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain();master.gain.value=0.9;master.connect(AC.destination);
const len=AC.sampleRate*2,buf=AC.createBuffer(1,len,AC.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
const ws=AC.createBufferSource();ws.buffer=buf;ws.loop=true;
windFilt=AC.createBiquadFilter();windFilt.type='lowpass';windFilt.frequency.value=200;windFilt.Q.value=0.6;
windGain=AC.createGain();windGain.gain.value=0;
ws.connect(windFilt);windFilt.connect(windGain);windGain.connect(master);ws.start();
const rs=AC.createBufferSource();rs.buffer=buf;rs.loop=true;rs.playbackRate.value=0.7;
const rf=AC.createBiquadFilter();rf.type='highpass';rf.frequency.value=2600;
rainGain=AC.createGain();rainGain.gain.value=0;
rs.connect(rf);rf.connect(rainGain);rainGain.connect(master);rs.start();
}catch(e){AC=null;}
}
function thump(freq,dur,vol){
if(!AC||!soundOn)return;
try{
const len=Math.max(1,Math.floor(AC.sampleRate*dur)),b=AC.createBuffer(1,len,AC.sampleRate),dd=b.getChannelData(0);
for(let i=0;i<len;i++){const t=i/len;dd[i]=(Math.random()*2-1)*Math.pow(1-t,2.2);}
const src=AC.createBufferSource();src.buffer=b;
const f=AC.createBiquadFilter();f.type='bandpass';f.frequency.value=freq;f.Q.value=1.1;
const g=AC.createGain();g.gain.value=vol;
src.connect(f);f.connect(g);g.connect(master);src.start();
}catch(e){}
}
function setSound(on){soundOn=on;
document.getElementById('soundBtn').firstChild.nodeValue=on?'SOUND ON':'SOUND OFF';
if(AC&&AC.state==='suspended')AC.resume();}
function toggleSound(){initAudio();soundStarted=true;setSound(!soundOn);}
function ensureAudio(){initAudio();if(AC&&!soundStarted){soundStarted=true;setSound(true);}
if(AC&&AC.state==='suspended')AC.resume();}
/* ---------------- wind ---------------- */
const wind={target:6,auto:true,actual:6,gust:0,q:0,ms:6/3.6};
let simT=0;
/* ---------------- umbrella constants & rest shapes ---------------- */
const SH=1.05,M=8,SEG=7,RC=0.78,PSI=0.96;
const A_NODE=0.09,M_NODE=0.03,KB=170,KPHI=520,CPHI=34,I_UMB=0.85,GRAV=9.8;
function arcRest(sx,psMax,psSign){const a=[];
for(let i=0;i<M;i++){const ps=psSign*psMax*(i/SEG);
a.push({x:sx*Math.sin(ps)*RC,y:(SH-RC)+Math.cos(ps)*RC});}return a;}
function bundleRest(sx){const a=[];for(let i=0;i<M;i++)a.push({x:sx*0.02*i,y:SH-0.135*i});return a;}
function rotPts(pts,cx,cy,ang){const c=Math.cos(ang),s=Math.sin(ang);
return pts.map(p=>({x:cx+(p.x-cx)*c-(p.y-cy)*s,y:cy+(p.x-cx)*s+(p.y-cy)*c}));}
const REST_OPEN={L:arcRest(-1,PSI,1),R:arcRest(1,PSI,1)};
const REST_CLOSED={L:bundleRest(-1),R:bundleRest(1)};
const REST_INV={L:rotPts(REST_OPEN.L,0,SH,1.31),R:rotPts(REST_OPEN.R,0,SH,1.18)};
/* ---------------- umbrella state ---------------- */
let umb=null;
function makeUmbrella(){
const u={state:'opening',openT:0,inverted:false,phi:0.10,phiVel:0,
grip:{x:0,y:0},w_inv:62+Math.random()*14,tauMax:255*(0.88+Math.random()*0.28),
satTime:0,invTimer:0,tauExt:0,saturated:false,cdMul:1,
beams:{L:[],R:[]},pos:null,vel:null,spin:0.1,omega:0,strapT:0};
for(const b of['L','R'])for(let i=0;i<M;i++)u.beams[b].push({x:0,y:0,px:0,py:0,fx:0,fy:0});
return u;
}
function currentRest(u){
if(u.inverted)return REST_INV;
if(u.state==='opening'){const t=u.openT*u.openT*(3-2*u.openT),out={};
for(const b of['L','R'])out[b]=REST_CLOSED[b].map((p,i)=>({x:lerp(p.x,REST_OPEN[b][i].x,t),y:lerp(p.y,REST_OPEN[b][i].y,t)}));
return out;}
return REST_OPEN;
}
function placeRest(u){
const rest=currentRest(u),c=Math.cos(u.phi),s=Math.sin(u.phi);
for(const b of['L','R'])for(let i=0;i<M;i++){
const w=rotV(rest[b][i],c,s),p=u.beams[b][i];
p.x=u.grip.x+w.x;p.y=u.grip.y+w.y;p.px=p.x;p.py=p.y;}
}
/* ---------------- person ---------------- */
const person={x:-1.6,lean:0.04,load:0,crouch:0,stance:0,headUp:0,lunge:0,
brace:false,braceMeter:1,recoil:0,pts:null,hand:{x:0,y:0},hand2:{x:0,y:0}};
const pivotOff={x:0,y:0},pivotOffV={x:0,y:0};
function ik(ax,ay,bx,by,l1,l2,bend){
let dx=bx-ax,dy=by-ay,d=Math.hypot(dx,dy);
if(d<1e-5){dx=0;dy=-1;d=1;}
const dm=Math.min(d,l1+l2-0.01),ux=dx/d,uy=dy/d;
const a=(l1*l1-l2*l2+dm*dm)/(2*dm);
const hh=Math.sqrt(Math.max(0,l1*l1-a*a));
return{x:ax+ux*a+uy*hh*bend,y:ay+uy*a-ux*hh*bend};
}
function computeSkeleton(){
const p=person,braceOn=p.brace&&p.braceMeter>0.02;
const tr=p.load*(braceOn?0.4:1);
const ln=p.lean+(Math.sin(simT*29)+0.6*Math.sin(simT*47))*0.013*tr;
const sl=Math.sin(ln),cl=Math.cos(ln);
const hip={x:p.x+0.14*sl+p.lunge,y:0.94-0.09*p.crouch};
const sh={x:hip.x-sl*0.52,y:hip.y+cl*0.52};
const head={x:sh.x-0.02-p.headUp*0.06,y:sh.y+0.235};
const sw=0.16+0.36*p.stance;
const fFoot={x:p.x-0.18-sw*0.55+p.lunge*0.4,y:0.03};
const bFoot={x:p.x+0.20+sw*0.45+p.lunge*0.4,y:0.03};
const kneeF=ik(hip.x,hip.y,fFoot.x,fFoot.y,0.47,0.47,1);
const kneeB=ik(hip.x,hip.y,bFoot.x,bFoot.y,0.47,0.47,1);
const holding=umb&&umb.state!=='free'&&umb.state!=='gone';
let h1,h2;
if(holding){
const c=Math.cos(umb.phi),s=Math.sin(umb.phi),sd=rotV({x:0,y:1},c,s);
const up=0.27+0.18*p.load;
h1={x:umb.grip.x,y:umb.grip.y};
h2={x:umb.grip.x+sd.x*up,y:umb.grip.y+sd.y*up};
}else{
h1={x:sh.x-0.10,y:sh.y-0.55};h2={x:sh.x+0.06,y:sh.y-0.53};
}
const elbF=ik(sh.x,sh.y,h1.x,h1.y,0.30,0.29,1);
const elbB=ik(sh.x+0.05,sh.y,h2.x,h2.y,0.30,0.29,1);
p.hand=h1;p.hand2=h2;
p.pts={hip,sh,head,fFoot,bFoot,kneeF,kneeB,h1,h2,elbF,elbB,holding};
}
/* ---------------- particles & scenery ---------------- */
const RAIN_N=420,rain=[],splashes=[],streaks=[],leaves=[],ripples=[],clouds=[];
const tufts=[-4.4,-3.6,-2.8,-0.55,4.7,5.5,6.2];
let leafTimer=0;
function initRain(){
rain.length=0;
for(let i=0;i<RAIN_N;i++)rain.push({x:wl+Math.random()*(wr-wl),y:Math.random()*7,
z:0.3+Math.random()*0.7,vx:0,vy:-8});
for(let i=0;i<160;i++)splashes.push({on:false,x:0,y:0,vx:0,vy:0,life:0});
for(let i=0;i<22;i++)streaks.push({x:wl+Math.random()*(wr-wl),y:0.3+Math.random()*4.5,z:0.4+Math.random()*0.6,ph:Math.random()*TAU});
for(let i=0;i<12;i++)leaves.push({on:false,x:0,y:0,rot:0,om:0,ph:Math.random()*TAU});
for(let i=0;i<24;i++)ripples.push({on:false,x:0,r:0,life:0});
for(let i=0;i<4;i++)clouds.push({x:Math.random()*14-7,y:3.4+Math.random()*2.4,w:3+Math.random()*3,h:0.5+Math.random()*0.5});
}
function spawnSplash(x,y,p){
for(const s of splashes){if(!s.on){s.on=true;s.x=x;s.y=y;
s.vx=(Math.random()-0.5)*2.2+wind.ms*0.4;s.vy=(0.8+Math.random()*1.6)*p*0.6;
s.life=0.28+Math.random()*0.15;return;}}
}
/* ---------------- scarf & coat-tail strips ---------------- */
const scarf=[],coat=[];
for(let i=0;i<6;i++)scarf.push({x:0,y:0,vx:0,vy:0});
for(let i=0;i<5;i++)coat.push({x:0,y:0,vx:0,vy:0});
function updateStrip(st,ax,ay,seg,dt,flut){
if(!st.init){st.init=true;
for(let i=0;i<st.length;i++){st[i].x=ax-i*seg*0.7;st[i].y=ay-i*seg;st[i].vx=0;st[i].vy=0;}}
st[0].x=ax;st[0].y=ay;
const wm=wind.ms;
for(let i=1;i<st.length;i++){
const s=st[i];
const fl=Math.sin(simT*8+i*1.9)*0.25*flut*(0.2+wm*0.06)+Math.sin(simT*21+i*2.7)*0.06*flut;
const tvx=wm*0.9+fl,tvy=-1.3+Math.sin(simT*6.3+i)*0.25;
const k=Math.min(1,6*dt);
s.vx+=(tvx-s.vx)*k;s.vy+=(tvy-s.vy)*k;
s.x+=s.vx*dt;s.y+=s.vy*dt;
}
for(let it=0;it<2;it++){
for(let i=1;i<st.length;i++){
const a=st[i-1],s=st[i];
let dx=s.x-a.x,dy=s.y-a.y;const d=Math.hypot(dx,dy)||1e-6;
const k=(d-seg)/d;
if(i===1){s.x-=dx*k;s.y-=dy*k;}
else{s.x-=dx*k*0.75;s.y-=dy*k*0.75;a.x+=dx*k*0.25;a.y+=dy*k*0.25;}
}
}
}
function updateScarfCoat(dt){
const P=person.pts;
updateStrip(scarf,P.head.x-0.10,P.head.y-0.14,0.085,dt,1);
updateStrip(coat,P.hip.x+0.12,P.hip.y+0.02,0.105,dt,0.8);
}
/* ---------------- umbrella physics ---------------- */
function canopyForces(u,rest,c,s,pivot){
let tau=0;
const wvx=wind.ms;
const openF=u.state==='opening'?(0.12+0.88*u.openT):1;
const Pint=0.18*wind.q*openF,Ae=A_NODE*openF;
const smx=pivot.x+s*SH*0.5,smy=pivot.y+c*SH*0.5;
for(const b of['L','R']){
const bm=u.beams[b];
for(let i=1;i<=SEG;i++){
const p=bm[i],pa=bm[i-1],pb=bm[i<SEG?i+1:i];
let tx=pb.x-pa.x,ty=pb.y-pa.y;const tl=Math.hypot(tx,ty)||1e-6;tx/=tl;ty/=tl;
let nx=-ty,ny=tx;
if(nx*(p.x-smx)+ny*(p.y-smy)<0){nx=-nx;ny=-ny;}
const wn=wvx*nx;
let Fx=((wind.q*Ae*wn+Pint*Ae)*nx+0.15*wind.q*Ae*(wvx-wn*nx))*u.cdMul;
let Fy=((wind.q*Ae*wn+Pint*Ae)*ny-0.15*wind.q*Ae*wn*ny)*u.cdMul;
const Fm=Math.hypot(Fx,Fy);if(Fm>60){Fx*=60/Fm;Fy*=60/Fm;}
p.fx+=Fx;p.fy+=Fy-M_NODE*GRAV;
const rx=p.x-pivot.x,ry=p.y-pivot.y;
tau+=-(rx*Fy-ry*Fx)+rx*M_NODE*GRAV;
}
}
return tau;
}
function stepCanopy(u,h,apex,rest,c,s){
for(const b of['L','R']){const p0=u.beams[b][0];
p0.x=apex.x;p0.y=apex.y;p0.px=apex.x;p0.py=apex.y;}
for(const b of['L','R']){
const bm=u.beams[b],rp=rest[b];
for(let i=1;i<SEG;i++){
const p=bm[i];
const mx=(bm[i-1].x+bm[i+1].x)*0.5,my=(bm[i-1].y+bm[i+1].y)*0.5;
const ox=rp[i].x-(rp[i-1].x+rp[i+1].x)*0.5,oy=rp[i].y-(rp[i-1].y+rp[i+1].y)*0.5;
const wo=rotV({x:ox,y:oy},c,s);
p.fx+=KB*(mx+wo.x-p.x);p.fy+=KB*(my+wo.y-p.y);
}
}
const h2=h*h;
for(const b of['L','R']){
const bm=u.beams[b];
for(let i=1;i<=SEG;i++){
const p=bm[i];
const vx=(p.x-p.px)*0.996,vy=(p.y-p.py)*0.996;
p.px=p.x;p.py=p.y;
p.x+=vx+p.fx/M_NODE*h2;p.y+=vy+p.fy/M_NODE*h2;
p.fx=0;p.fy=0;
}
}
for(let it=0;it<3;it++){
for(const b of['L','R']){
const bm=u.beams[b],rp=rest[b];
for(let i=1;i<=SEG;i++){
const a=bm[i-1],p=bm[i];
let dx=p.x-a.x,dy=p.y-a.y;const d=Math.hypot(dx,dy)||1e-6;
const rl=Math.hypot(rp[i].x-rp[i-1].x,rp[i].y-rp[i-1].y);
const diff=(d-rl)/d;
if(i===1){p.x-=dx*diff;p.y-=dy*diff;}
else{const k=0.5*diff;a.x+=dx*k;a.y+=dy*k;p.x-=dx*k;p.y-=dy*k;}
}
const rim=bm[SEG];
let dx=rim.x-apex.x,dy=rim.y-apex.y;const d=Math.hypot(dx,dy)||1e-6;
const rl=Math.hypot(rp[SEG].x-rp[0].x,rp[SEG].y-rp[0].y);
const diff=(d-rl)/d;
rim.x-=dx*diff;rim.y-=dy*diff;
}
}
}
function doInvert(u,apex){
u.inverted=true;u.state='inverted';u.cdMul=1.55;u.invTimer=0;
const rest=REST_INV,c=Math.cos(u.phi),s=Math.sin(u.phi);
for(const b of['L','R'])for(let i=1;i<=SEG;i++){
const p=u.beams[b][i],w=rotV(rest[b][i],c,s);
const nx=u.grip.x+w.x,ny=u.grip.y+w.y;
p.px=p.x-(nx-p.x)*0.45;p.py=p.y-(ny-p.y)*0.45;
p.x=nx;p.y=ny;
}
addShake(0.35);thump(750,0.14,0.55);
}
function ripAway(u){
u.state='free';
u.pos={x:u.grip.x,y:u.grip.y};
u.vel={x:wind.ms*0.45+1.2,y:2.0};
u.spin=u.phi;u.omega=4+Math.random()*5;u.strapT=0.55;
person.recoil=1;
addShake(0.6);thump(240,0.3,0.7);
}
function stepHeld(u,h){
if(u.state==='opening'){u.openT=Math.min(1,u.openT+h/0.9);if(u.openT>=1)u.state='open';}
const c=Math.cos(u.phi),s=Math.sin(u.phi);
const apex={x:u.grip.x+s*SH,y:u.grip.y+c*SH};
const rest=currentRest(u);
const tau=canopyForces(u,rest,c,s,u.grip);
u.tauExt=tau;
const phi0=-(0.08+0.20*Math.min(wind.actual,70)/70);
const tMax=u.tauMax*(person.brace&&person.braceMeter>0.02?1.6:1);
const tg=-KPHI*(u.phi-phi0)-CPHI*u.phiVel;
u.saturated=Math.abs(tg)>tMax;
if(u.saturated)u.satTime+=h;else u.satTime=Math.max(0,u.satTime-2*h);
const tgC=clamp(tg,-tMax,tMax);
u.phiVel+=((tau+tgC)/I_UMB)*h;
u.phiVel*=(1-(u.saturated?3.0:0.4)*h);
u.phiVel=clamp(u.phiVel,-9,9);
u.phi=clamp(u.phi+u.phiVel*h,-1.2,3.6);
if(!u.inverted&&u.state!=='opening'){
if(wind.actual>u.w_inv)u.invTimer+=h;else u.invTimer=Math.max(0,u.invTimer-h);
const rimR=u.beams.R[SEG],rimL=u.beams.L[SEG];
const rimAbove=Math.max(rimR.y,rimL.y)>apex.y+0.12&&Math.abs(u.phi)<0.8&&wind.actual>45;
if(u.invTimer>0.4||rimAbove)doInvert(u,apex);
}
if(u.satTime>0.22||u.phi>1.7||u.phi<-0.9||wind.actual>125){ripAway(u);return;}
stepCanopy(u,h,apex,currentRest(u),c,s);
}
function stepFree(u,h){
u.vel.x+=(wind.ms*1.02-u.vel.x)*1.8*h;
u.vel.y+=(-GRAV-(u.vel.y+6)*1.1)*h;
u.pos.x+=u.vel.x*h;u.pos.y+=u.vel.y*h;
u.spin+=u.omega*h;u.omega*=(1-0.15*h);
if(u.strapT>0){
u.strapT-=h;
const dx=person.hand.x-u.pos.x,dy=person.hand.y-u.pos.y,d=Math.hypot(dx,dy);
if(d>0.95){const k=(d-0.95)/d*0.5;u.pos.x+=dx*k;u.pos.y+=dy*k;person.lunge+=dx*k*0.4;}
if(u.strapT<=0){thump(950,0.09,0.45);addShake(0.15);
for(let i=0;i<6;i++)spawnSplash(u.pos.x,u.pos.y,1.2);}
}
if(u.pos.y<0.13){
u.pos.y=0.13;
if(u.vel.y<-0.5){u.vel.y*=-0.38;u.omega*=0.75;}else u.vel.y=0;
u.vel.x-=u.vel.x*2.5*h;
}
const c=Math.cos(u.spin),s=Math.sin(u.spin);
const apex={x:u.pos.x+s*SH,y:u.pos.y+c*SH};
const rest=currentRest(u);
canopyForces(u,rest,c,s,u.pos);
stepCanopy(u,h,apex,rest,c,s);
if(u.pos.x>wr+4||u.pos.y>9)u.state='gone';
}
function step(h){
simT+=h;
if(wind.auto)wind.target=clamp(wind.target+0.95*h,0,130);
const gAmp=2.2+wind.target*0.24;
const g=gAmp*(0.45*Math.sin(simT*1.13+1.7)+0.30*Math.sin(simT*2.71+0.4)
+0.25*Math.sin(simT*5.31+2.1))+gAmp*0.3*Math.sin(simT*0.21);
wind.gust+=(g-wind.gust)*(1-Math.exp(-3.5*h));
wind.actual=Math.max(0,wind.target+wind.gust);
wind.ms=wind.actual/3.6;
wind.q=0.5*1.225*wind.ms*wind.ms;
const u=umb;if(!u)return;
const held=u.state==='open'||u.state==='inverted'||u.state==='opening';
// arm compliance: the grip point gives way under pull
const shp=person.pts.sh;
const pull=0.10*wind.q*1.29*u.cdMul*(held?1:0);
const ax=(-140*pivotOff.x-14*pivotOffV.x+pull)/1.2;
const ay=(-140*pivotOff.y-14*pivotOffV.y)/1.2;
pivotOffV.x+=ax*h;pivotOffV.y+=ay*h;
pivotOff.x=clamp(pivotOff.x+pivotOffV.x*h,-0.06,0.34);
pivotOff.y=clamp(pivotOff.y+pivotOffV.y*h,-0.2,0.2);
u.grip.x=shp.x-0.30+pivotOff.x;
u.grip.y=shp.y-0.14+pivotOff.y;
if(held)stepHeld(u,h);
else if(u.state==='free')stepFree(u,h);
}
/* ---------------- person update ---------------- */
function updatePerson(dt){
const u=umb;
const held=u&&u.state!=='free'&&u.state!=='gone';
let loadT=0;
if(held)loadT=clamp(Math.abs(u.tauExt)/(u.tauMax*1.15),0,1.4);
person.load+=(loadT-person.load)*(1-Math.exp(-4*dt));
person.brace=keys.has('Space')||braceHeld;
if(person.brace&&person.braceMeter>0)person.braceMeter=Math.max(0,person.braceMeter-dt/3.2);
else person.braceMeter=Math.min(1,person.braceMeter+dt/7);
const braceOn=person.brace&&person.braceMeter>0.02;
let leanT=held?(0.05+0.42*Math.min(person.load,1.15))*(braceOn?1.18:1):0.03;
leanT-=person.recoil*0.8;
person.lean+=(leanT-person.lean)*(1-Math.exp(-3.5*dt));
person.crouch+=((held?clamp(person.load,0,1):0)+(braceOn?0.25:0)-person.crouch)*(1-Math.exp(-4*dt));
person.stance+=((held?clamp(person.load*1.1,0,1.2):0)+(braceOn?0.35:0)-person.stance)*(1-Math.exp(-4*dt));
person.recoil=Math.max(0,person.recoil-dt*1.4);
person.lunge*=Math.max(0,1-3*dt);
const headTgt=(!held&&u&&u.state==='gone')?1:0;
person.headUp+=(headTgt-person.headUp)*(1-Math.exp(-2*dt));
}
/* ---------------- weather particles ---------------- */
function updateWeather(dt){
const wms=wind.ms,k=1-Math.exp(-2.5*dt);
for(const r of rain){
const tx=wms*(0.45+0.55*r.z),ty=-(7.5+4.5*r.z);
r.vx+=(tx-r.vx)*k;r.vy+=(ty-r.vy)*k;
r.x+=r.vx*dt;r.y+=r.vy*dt;
if(r.y<0.02){if(r.z>0.5)spawnSplash(r.x,0.03,0.7+r.z*0.5);
r.y=6.6+Math.random()*1.6;r.x=wl-2+Math.random()*(wr-wl+4);}
if(r.x>wr+2.5){r.x=wl-1-Math.random()*1.5;r.y=0.5+Math.random()*5.5;}
if(r.x<wl-3)r.x=wl-1;
}
for(const s of splashes){if(!s.on)continue;s.life-=dt;
if(s.life<=0){s.on=false;continue;}
s.vy-=16*dt;s.x+=s.vx*dt;s.y+=s.vy*dt;if(s.y<0)s.on=false;}
for(const st of streaks){st.x+=wms*(0.75+0.35*st.z)*dt*1.5;st.ph+=dt;
if(st.x>wr+1){st.x=wl-1;st.y=0.3+Math.random()*4.5;}}
leafTimer-=dt;
if(wind.actual>28&&leafTimer<=0){
leafTimer=Math.max(0.4,1.6+Math.random()*3-wind.actual*0.01);
for(const l of leaves){if(!l.on){l.on=true;l.x=wl-1;l.y=0.25+Math.random()*2.4;
l.rot=Math.random()*TAU;l.om=(4+Math.random()*8)*(Math.random()<0.5?-1:1);break;}}}
for(const l of leaves){if(!l.on)continue;
l.x+=wms*0.8*dt;l.y+=(Math.sin(simT*6+l.ph)*1.3-0.2)*dt;l.rot+=l.om*dt;
if(l.y<0.06)l.y=0.06;if(l.x>wr+2)l.on=false;}
if(Math.random()<dt*7){
for(const rp of ripples){if(!rp.on){rp.on=true;rp.x=2.7+(Math.random()-0.5)*2.6;rp.r=0.03;rp.life=1;break;}}}
for(const rp of ripples){if(!rp.on)continue;rp.r+=dt*0.45;rp.life-=dt*1.3;if(rp.life<=0)rp.on=false;}
for(const cl of clouds){cl.x+=wms*0.12*dt;if(cl.x-cl.w>wr)cl.x=wl-cl.w*2;}
}
/* ---------------- rendering ---------------- */
let shake=0,flash=0,nextBolt=8;
function addShake(a){shake=Math.max(shake,a);}
function drawClouds(){
ctx.fillStyle='rgba(13,18,25,0.6)';
for(const c of clouds){ctx.beginPath();
ctx.ellipse(c.x,c.y,c.w,c.h,0,0,TAU);
ctx.ellipse(c.x+c.w*0.5,c.y+c.h*0.25,c.w*0.6,c.h*0.7,0,0,TAU);
ctx.ellipse(c.x-c.w*0.55,c.y+c.h*0.2,c.w*0.5,c.h*0.6,0,0,TAU);
ctx.fill();}
}
function drawTreeline(){
ctx.fillStyle='#151b22';ctx.beginPath();ctx.moveTo(wl-2,0);
for(const b of treeBumps){ctx.lineTo(b.x,0.05);ctx.lineTo(b.x+0.18,b.h);}
ctx.lineTo(wr+2,0);ctx.closePath();ctx.fill();
}
function drawRain(zMax){
const front=zMax>1;
ctx.strokeStyle=front?'rgba(170,188,204,0.42)':'rgba(170,188,204,0.20)';
ctx.lineWidth=front?0.02:0.012;ctx.lineCap='butt';
ctx.beginPath();
for(const r of rain){
if(front?r.z<0.62:r.z>=0.62)continue;
ctx.moveTo(r.x,r.y);ctx.lineTo(r.x-r.vx*0.045,r.y-r.vy*0.045);
}
ctx.stroke();
}
function drawPuddle(){
ctx.fillStyle='#2b3844';ctx.beginPath();ctx.ellipse(2.7,-0.04,1.5,0.13,0,0,TAU);ctx.fill();
ctx.strokeStyle='rgba(255,255,255,0.06)';ctx.lineWidth=0.01;ctx.stroke();
}
function drawRipples(){
ctx.strokeStyle='rgba(159,180,196,0.5)';ctx.lineWidth=0.008;
for(const rp of ripples){if(!rp.on)continue;
ctx.globalAlpha=Math.max(0,rp.life)*0.5;
ctx.beginPath();ctx.ellipse(rp.x,-0.04,rp.r*3,rp.r*0.28,0,0,TAU);ctx.stroke();}
ctx.globalAlpha=1;
}
function drawGrass(){
ctx.strokeStyle='#46523f';ctx.lineWidth=0.016;ctx.lineCap='round';
const bend=clamp(wind.ms*0.055,0,0.85);
tufts.forEach((t0,ti)=>{
for(let j=0;j<4;j++){
const bx=t0+j*0.045-0.07;
const h=0.16+((j*7+ti*3)%3)*0.035;
const fl=Math.sin(simT*9+j*2.1+ti)*0.04*(0.3+wind.ms*0.05);
const tx=bx+bend*0.55+fl,ty=h*(1-bend*0.75);
ctx.beginPath();ctx.moveTo(bx,0);
ctx.quadraticCurveTo(bx+bend*0.15,h*0.6,tx,ty);ctx.stroke();
}});
}
function drawLeaves(){
ctx.fillStyle='#5a4632';
for(const l of leaves){if(!l.on)continue;
ctx.save();ctx.translate(l.x,l.y);ctx.rotate(l.rot);
ctx.beginPath();ctx.ellipse(0,0,0.055,0.028,0,0,TAU);ctx.fill();ctx.restore();}
}
function drawShadow(){
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath();ctx.ellipse(person.x+0.15+person.lunge,0.015,0.5+0.3*person.stance,0.055,0,0,TAU);ctx.fill();
}
function drawSplashes(){
ctx.fillStyle='rgba(200,215,228,0.7)';
for(const s of splashes){if(!s.on)continue;
ctx.beginPath();ctx.arc(s.x,s.y,0.012,0,TAU);ctx.fill();}
}
function drawStreaks(){
const a=clamp((wind.actual-14)/50,0,0.4);if(a<=0.01)return;
ctx.strokeStyle='rgba(205,220,232,'+a.toFixed(3)+')';ctx.lineWidth=0.012;ctx.lineCap='butt';
const len=Math.max(0.3,wind.ms*0.09);
ctx.beginPath();
for(const st of streaks){ctx.moveTo(st.x,st.y);ctx.lineTo(st.x-len,st.y+0.03*Math.sin(st.ph*3));}
ctx.stroke();
}
function boot(x,y,dark){
ctx.fillStyle=dark?'#101318':'#14171b';
ctx.beginPath();ctx.ellipse(x-0.05,y+0.02,0.09,0.045,0,0,TAU);ctx.fill();
}
function drawPersonBack(){
const P=person.pts;
ctx.lineCap='round';ctx.lineJoin='round';
// coat tail
ctx.strokeStyle='#1e2229';
for(let i=1;i<coat.length;i++){ctx.lineWidth=0.16-i*0.026;
ctx.beginPath();ctx.moveTo(coat[i-1].x,coat[i-1].y);ctx.lineTo(coat[i].x,coat[i].y);ctx.stroke();}
// far leg + far arm
ctx.strokeStyle='#1b1f26';ctx.lineWidth=0.105;
ctx.beginPath();ctx.moveTo(P.hip.x,P.hip.y);ctx.lineTo(P.kneeB.x,P.kneeB.y);ctx.lineTo(P.bFoot.x,P.bFoot.y);ctx.stroke();
boot(P.bFoot.x,P.bFoot.y,true);
ctx.lineWidth=0.085;
ctx.beginPath();ctx.moveTo(P.sh.x+0.05,P.sh.y);ctx.lineTo(P.elbB.x,P.elbB.y);ctx.lineTo(P.h2.x,P.h2.y);ctx.stroke();
}
function drawPersonFront(){
const P=person.pts;
ctx.lineCap='round';ctx.lineJoin='round';
// near leg
ctx.strokeStyle='#262b33';ctx.lineWidth=0.105;
ctx.beginPath();ctx.moveTo(P.hip.x,P.hip.y);ctx.lineTo(P.kneeF.x,P.kneeF.y);ctx.lineTo(P.fFoot.x,P.fFoot.y);ctx.stroke();
boot(P.fFoot.x,P.fFoot.y,false);
// torso
ctx.lineWidth=0.21;
ctx.beginPath();ctx.moveTo(P.hip.x,P.hip.y);ctx.lineTo(P.sh.x,P.sh.y);ctx.stroke();
// head
ctx.fillStyle='#d8a67f';ctx.beginPath();ctx.arc(P.head.x,P.head.y,0.125,0,TAU);ctx.fill();
// beanie
ctx.strokeStyle='#8a4a32';ctx.lineWidth=0.07;
ctx.beginPath();ctx.arc(P.head.x,P.head.y,0.115,Math.PI*0.08,Math.PI*0.92);ctx.stroke();
ctx.fillStyle='#a86b4a';ctx.beginPath();ctx.arc(P.head.x,P.head.y+0.15,0.032,0,TAU);ctx.fill();
// face (facing upwind)
const hy=P.head.y+person.headUp*0.05;
ctx.fillStyle='#2a2018';ctx.beginPath();ctx.arc(P.head.x-0.055,hy+0.015,0.013,0,TAU);ctx.fill();
ctx.fillStyle='#c08a63';ctx.beginPath();ctx.arc(P.head.x-0.125,hy-0.005,0.022,0,TAU);ctx.fill();
// scarf
ctx.strokeStyle='#6fa0a6';
for(let i=1;i<scarf.length;i++){ctx.lineWidth=0.075-i*0.008;
ctx.beginPath();ctx.moveTo(scarf[i-1].x,scarf[i-1].y);ctx.lineTo(scarf[i].x,scarf[i].y);ctx.stroke();}
// near arm
ctx.strokeStyle='#262b33';ctx.lineWidth=0.085;
ctx.beginPath();ctx.moveTo(P.sh.x,P.sh.y);ctx.lineTo(P.elbF.x,P.elbF.y);ctx.lineTo(P.h1.x,P.h1.y);ctx.stroke();
// hands
ctx.fillStyle='#d8a67f';
ctx.beginPath();ctx.arc(P.h1.x,P.h1.y,0.04,0,TAU);ctx.fill();
ctx.beginPath();ctx.arc(P.h2.x,P.h2.y,0.038,0,TAU);ctx.fill();
}
function drawUmbrella(u){
const held=u.state!=='free';
const gx=held?u.grip.x:u.pos.x,gy=held?u.grip.y:u.pos.y;
const phi=held?u.phi:u.spin;
const c=Math.cos(phi),s=Math.sin(phi);
const apex={x:gx+s*SH,y:gy+c*SH};
const stress=clamp(person.load,0,1);
ctx.save();
if(stress>0.7&&held){const j=(stress-0.7)*0.03;
ctx.translate((Math.random()-0.5)*j,(Math.random()-0.5)*j);}
ctx.lineCap='round';
// shaft + handle + strap loop
ctx.strokeStyle='#4a3b28';ctx.lineWidth=0.034;
ctx.beginPath();ctx.moveTo(gx-s*0.17,gy-c*0.17);ctx.lineTo(apex.x,apex.y);ctx.stroke();
ctx.lineWidth=0.05;ctx.beginPath();ctx.moveTo(gx,gy);ctx.lineTo(gx-s*0.17,gy-c*0.17);ctx.stroke();
ctx.lineWidth=0.035;ctx.beginPath();ctx.arc(gx-s*0.21,gy-c*0.17,0.045,0,TAU);ctx.stroke();
ctx.strokeStyle='#7a6a4a';ctx.lineWidth=0.014;
ctx.beginPath();ctx.arc(gx-s*0.27,gy-c*0.17,0.03,0,TAU);ctx.stroke();
// fabric
const bl=u.beams.L,br=u.beams.R,rimL=bl[SEG],rimR=br[SEG];
ctx.beginPath();
ctx.moveTo(rimL.x,rimL.y);
for(let i=SEG-1;i>=0;i--)ctx.lineTo(bl[i].x,bl[i].y);
for(let i=1;i<=SEG;i++)ctx.lineTo(br[i].x,br[i].y);
for(let i=0;i<4;i++){
const t0=i/4,t1=(i+1)/4;
const ax=rimR.x+(rimL.x-rimR.x)*t0,ay=rimR.y+(rimL.y-rimR.y)*t0;
const bx=rimR.x+(rimL.x-rimR.x)*t1,by=rimR.y+(rimL.y-rimR.y)*t1;
let dx=apex.x-(ax+bx)/2,dy=apex.y-(ay+by)/2;const dl=Math.hypot(dx,dy)||1;
ctx.quadraticCurveTo((ax+bx)/2+dx/dl*0.06,(ay+by)/2+dy/dl*0.06,bx,by);
}
ctx.closePath();
ctx.fillStyle=u.inverted?'#c6942e':'#e2a63b';ctx.fill();
ctx.strokeStyle='rgba(20,16,8,0.35)';ctx.lineWidth=0.012;ctx.stroke();
// ribs, heating up with load
const rc=mixColor([122,95,42],[255,90,60],(stress-0.35)/0.65);
ctx.strokeStyle='rgb('+rc[0]+','+rc[1]+','+rc[2]+')';ctx.lineWidth=0.02;
for(const bm of[bl,br]){ctx.beginPath();ctx.moveTo(bm[0].x,bm[0].y);
for(let i=1;i<=SEG;i++)ctx.lineTo(bm[i].x,bm[i].y);ctx.stroke();}
ctx.fillStyle='rgb('+rc[0]+','+rc[1]+','+rc[2]+')';
ctx.beginPath();ctx.arc(rimL.x,rimL.y,0.024,0,TAU);ctx.fill();
ctx.beginPath();ctx.arc(rimR.x,rimR.y,0.024,0,TAU);ctx.fill();
ctx.fillStyle='#3a2f20';ctx.beginPath();ctx.arc(apex.x,apex.y,0.028,0,TAU);ctx.fill();
ctx.restore();
}
function drawStrap(){
const u=umb;if(!u||u.state!=='free'||u.strapT<=0)return;
ctx.strokeStyle='#3d3226';ctx.lineWidth=0.02;ctx.lineCap='round';
const mx=(person.hand.x+u.pos.x)/2,my=(person.hand.y+u.pos.y)/2-0.1;
ctx.beginPath();ctx.moveTo(person.hand.x,person.hand.y);
ctx.quadraticCurveTo(mx,my,u.pos.x,u.pos.y);ctx.stroke();
}
function render(){
ctx.setTransform(DPR,0,0,DPR,0,0);
const shx=(Math.random()-0.5)*shake*16,shy=(Math.random()-0.5)*shake*12;
ctx.translate(shx,shy);
const sg=ctx.createLinearGradient(0,0,0,H);
sg.addColorStop(0,'#0d1219');sg.addColorStop(0.55,'#202b36');sg.addColorStop(1,'#3a4754');
ctx.fillStyle=sg;ctx.fillRect(-20,-20,W+40,H+40);
if(flash>0){ctx.fillStyle='rgba(220,230,240,'+(flash*0.35).toFixed(3)+')';ctx.fillRect(-20,-20,W+40,H+40);}
ctx.save();ctx.translate(OX,OY);ctx.scale(S,-S);
drawClouds();drawTreeline();drawRain(0.62);
ctx.restore();
const gg=ctx.createLinearGradient(0,OY,0,H);
gg.addColorStop(0,'#252d35');gg.addColorStop(1,'#12161b');
ctx.fillStyle=gg;ctx.fillRect(-20,OY,W+40,H-OY+20);
ctx.strokeStyle='rgba(255,255,255,0.07)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(-20,OY);ctx.lineTo(W+20,OY);ctx.stroke();
ctx.save();ctx.translate(OX,OY);ctx.scale(S,-S);
drawPuddle();drawGrass();drawLeaves();drawShadow();
drawPersonBack();
if(umb&&umb.state!=='gone')drawUmbrella(umb);
drawPersonFront();drawStrap();
drawRain(2);drawSplashes();drawStreaks();drawRipples();
ctx.restore();
const vg=ctx.createRadialGradient(W/2,H*0.45,H*0.35,W/2,H*0.45,H*0.95);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(4,7,11,0.55)');
ctx.fillStyle=vg;ctx.fillRect(-20,-20,W+40,H+40);
}
/* ---------------- HUD ---------------- */
const windNumEl=document.getElementById('windNum'),beaufortEl=document.getElementById('beaufort'),
sparkLineEl=document.getElementById('sparkLine'),statusTextEl=document.getElementById('statusText'),
dotEl=document.getElementById('dot'),braceBarEl=document.getElementById('braceBar'),
windSliderEl=document.getElementById('windSlider'),autoRiseEl=document.getElementById('autoRise'),
resetBtn=document.getElementById('resetBtn'),soundBtn=document.getElementById('soundBtn'),
braceBtn=document.getElementById('braceBtn');
function beaufort(v){
const b=[[1,'calm'],[6,'light air'],[12,'light breeze'],[20,'gentle breeze'],[29,'moderate breeze'],
[39,'fresh breeze'],[50,'strong breeze'],[62,'near gale'],[75,'gale'],[89,'strong gale'],
[103,'storm'],[118,'violent storm'],[999,'hurricane']];
for(const[t,n]of b)if(v<t)return n;return'hurricane';
}
function statusInfo(){
const u=umb;if(!u)return['\u2014','#8899a6'];
if(u.state==='gone')return['GONE \u2014 THE WIND KEPT IT','#8899a6'];
if(u.state==='free')return[u.strapT>0?'THE STRAP IS HOLDING\u2026':'RIPPING AWAY','#ff5a3c'];
if(u.state==='opening')return['OPENING THE UMBRELLA','#9ab8a0'];
if(u.saturated&&Math.abs(u.phi)>0.65)return['WRENCHED SIDEWAYS','#ff5a3c'];
if(u.inverted)return['INSIDE OUT','#ff6b4a'];
if(person.brace&&person.braceMeter>0.02)return['BRACED','#7fa3a8'];
if(person.load>0.8)return['FRAME CREAKING','#e08a4a'];
if(person.load>0.45)return['FIGHTING THE WIND','#e0c06a'];
return['HOLDING FIRM','#9ab8a0'];
}
let sparkT=0;const hist=[];
function updateHUD(dt){
windNumEl.textContent=Math.round(wind.actual);
windNumEl.style.color=wind.actual>75?'#ff6b4a':(wind.actual>50?'#e0c06a':'#e8edf2');
beaufortEl.textContent=beaufort(wind.actual);
sparkT+=dt;
if(sparkT>0.1){sparkT=0;hist.push(wind.actual);if(hist.length>75)hist.shift();
let pts='';for(let i=0;i<hist.length;i++)pts+=(i*2.27).toFixed(1)+','+(28-clamp(hist[i],0,130)/130*26).toFixed(1)+' ';
sparkLineEl.setAttribute('points',pts);}
const si=statusInfo();
if(statusTextEl.textContent!==si[0])statusTextEl.textContent=si[0];
dotEl.style.background=si[1];dotEl.style.boxShadow='0 0 8px '+si[1];
braceBarEl.style.width=(person.braceMeter*100).toFixed(1)+'%';
if(!dragging&&performance.now()-lastSliderT>1200)windSliderEl.value=Math.round(wind.target);
}
/* ---------------- input ---------------- */
const keys=new Set();
let braceHeld=false,dragging=false,lastX=0,lastSliderT=0;
function setAuto(v){wind.auto=v;autoRiseEl.checked=v;}
function handleKeys(dt){
let d=0;
if(keys.has('ArrowRight')||keys.has('ArrowUp')||keys.has('KeyW'))d+=1;
if(keys.has('ArrowLeft')||keys.has('ArrowDown')||keys.has('KeyS'))d-=1;
if(d!==0){wind.target=clamp(wind.target+d*26*dt,0,130);setAuto(false);}
}
function resetUmbrella(){
umb=makeUmbrella();
const shp=person.pts?person.pts.sh:null;
umb.grip.x=(shp?shp.x:-1.9)-0.30;umb.grip.y=(shp?shp.y:1.4)-0.14;
placeRest(umb);
pivotOff.x=0;pivotOff.y=0;pivotOffV.x=0;pivotOffV.y=0;
}
window.addEventListener('keydown',e=>{
if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Space'].includes(e.code))e.preventDefault();
keys.add(e.code);
if(e.code==='KeyR')resetUmbrella();
if(e.code==='KeyM')toggleSound();
ensureAudio();
});
window.addEventListener('keyup',e=>keys.delete(e.code));
windSliderEl.addEventListener('input',()=>{wind.target=+windSliderEl.value;lastSliderT=performance.now();setAuto(false);});
autoRiseEl.addEventListener('change',()=>{wind.auto=autoRiseEl.checked;});
braceBtn.addEventListener('pointerdown',()=>{braceHeld=true;ensureAudio();});
cv.addEventListener('pointerdown',e=>{dragging=true;lastX=e.clientX;ensureAudio();});
window.addEventListener('pointermove',e=>{
if(!dragging)return;
const dx=e.clientX-lastX;lastX=e.clientX;
wind.target=clamp(wind.target+dx*0.3,0,130);
lastSliderT=performance.now();setAuto(false);
});
window.addEventListener('pointerup',()=>{dragging=false;braceHeld=false;});
resetBtn.addEventListener('click',()=>{resetUmbrella();resetBtn.blur();});
soundBtn.addEventListener('click',()=>{toggleSound();soundBtn.blur();});
window.addEventListener('pointerdown',ensureAudio);
/* ---------------- audio per-frame ---------------- */
function updateAudioFrame(){
if(!AC)return;
if(soundOn){
windFilt.frequency.value=120+wind.actual*26;
windGain.gain.value=Math.pow(wind.actual/130,1.35)*0.5;
rainGain.gain.value=0.05;
}else{windGain.gain.value=0;rainGain.gain.value=0;}
}
/* ---------------- main loop ---------------- */
resize();
initRain();
computeSkeleton();
resetUmbrella();
updateScarfCoat(0.016);
let last=performance.now(),acc=0;
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000;last=now;
if(dt<=0)return;if(dt>0.05)dt=0.05;
handleKeys(dt);
if(wind.actual>78&&simT>nextBolt){
nextBolt=simT+7+Math.random()*15;flash=0.7;addShake(0.2);
const dl=350+Math.random()*700;setTimeout(()=>thump(110,1.0,0.5),dl);
}
flash=Math.max(0,flash-dt*1.8);
shake=Math.max(0,shake-dt*1.7);
computeSkeleton();
acc+=dt;let n=0;
while(acc>=1/120&&n<6){step(1/120);acc-=1/120;n++;}
updatePerson(dt);
updateScarfCoat(dt);
updateWeather(dt);
updateAudioFrame();
updateHUD(dt);
render();
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Where it's honest: the canopy genuinely bows and flutters (gusts make the fabric shudder before anything breaks), the ribs visibly "heat up" as load rises, the shaft leans progressively against the wind, the stance widens, the coat tail and scarf stream, rain slants and streaks longer as wind grows, and the sequence — flex, creak, inside-out snap, wrench, strap stretch, rip, tumble — plays out with real physics timing rather than an animation timer. Sound is synthesized wind/rain that tracks the speed, with a snap on inversion and a low crack when it tears away.
Where it will disappoint an umbrella veteran: rain ignores the canopy entirely — there's no dry shadow under it, streaks draw right through the fabric, which is the first thing you'll notice. Inversion is a randomized wind threshold plus a forced flip, not a true buckling instability emerging from the rib geometry, so it can't be "earn" the flip by geometry alone, and an inverted umbrella can only be replaced, not re-closed in a lull. The person is procedural puppetry, not simulated balance: feet shuffle slightly when the stance widens, they'll never stumble, slip, or chase the umbrella down the street. The dome is faked from two 2D rib curves and a scalloped edge, so the canopy reads a bit thin when the shaft is wrenched near horizontal. The strap is a timed distance constraint, not a rope, and the gust field is a sum of sines — the same flavor of turbulence everywhere, with no lulls between squalls.