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 eight ribs around a pole axis, each rib a quadratic beam (apex → mid → tip) projected with a slight oblique depth so the dome can yaw and you can see panels sweep past as it slews; fabric is the eight scalloped panels between ribs, shaded by depth. Inversion is a bistable scalar s (0 = convex, 1 = concave) driven by a stiff spring, so when it trips the rib elevation angle snaps through the flat position with a visible overshoot — the tips whip up past the apex plane and the lining color takes over. The trip condition is aerodynamic moment vs. rib stiffness: dynamic pressure (∝ wind²) × the canopy's attack factor (how squarely the mouth faces the wind, from pole tilt) must hold above a critical value for ~0.2 s, or spike 30% over it in a single gust; a wrench event — where gust torque beats the holder's yaw strength and the canopy slews sideways — weakens that threshold, which is why the flip often comes right after a sideways yank. Hysteresis is built in: it only re-snaps flat once the wind drops well below the trip point, and once the total pull exceeds grip capacity (reduced when inverted, because the bowl is now a sail) the umbrella detaches and becomes a free body with its own tumble.
<!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 weather</title>
<style>
:root{
--amber:#ffb454; --amber-dim:#c98b3c; --red:#ff5d4d; --ok:#9fd8a6;
--panel:#0c141bf2; --line:#ffb4542e;
--disp:system-ui,-apple-system,"Segoe UI",sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;background:#16222b}
canvas{position:fixed;inset:0;display:block}
#hud{position:fixed;inset:0;pointer-events:none;font-family:var(--mono);color:#dfe8ee}
.panel{position:absolute;background:var(--panel);border:1px solid var(--line);
border-radius:3px;box-shadow:0 4px 18px #0008}
#windbox{top:16px;left:16px;padding:12px 16px 13px;min-width:236px}
#windbox .lbl{font-family:var(--disp);font-size:10px;letter-spacing:.34em;color:var(--amber-dim);font-weight:700}
#windbox .num{font-size:52px;font-weight:700;color:var(--amber);line-height:1.02;letter-spacing:-.02em}
#windbox .num small{font-size:14px;color:var(--amber-dim);font-weight:400;margin-left:4px}
#beaufort{font-family:var(--disp);font-weight:800;text-transform:uppercase;letter-spacing:.18em;
font-size:12px;color:#b9cad4;margin:2px 0 9px}
.mrow{display:flex;align-items:center;gap:8px;font-size:9px;letter-spacing:.22em;color:#7e93a0;margin-top:6px}
.mbar{flex:1;height:5px;background:#1b2833;border:1px solid #2c3f4d}
.mbar i{display:block;height:100%;background:var(--ok);width:100%;transition:width .12s linear}
#chip{display:inline-block;margin-top:9px;padding:3px 9px;border:1px solid;font-size:10px;
letter-spacing:.24em;font-weight:700}
.c-set{color:var(--ok);border-color:#4d7a58}
.c-flex{color:var(--amber);border-color:#8a6a34}
.c-inv{color:var(--red);border-color:#8a3a34;background:#3a151240}
.c-lost{color:#8fa3ad;border-color:#445866}
#flavor{margin-top:8px;font-size:11px;color:#8fa3ad;font-style:italic;max-width:230px;min-height:14px}
#brand{top:16px;right:16px;padding:10px 14px;text-align:right}
#brand h1{font-family:var(--disp);font-weight:900;font-size:19px;letter-spacing:.42em;color:#eef4f7}
#brand p{font-size:9.5px;color:#7e93a0;letter-spacing:.14em;margin-top:3px}
#brand .lost-ct{color:var(--red)}
#dock{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);pointer-events:auto;
display:flex;align-items:center;gap:14px;padding:11px 16px;max-width:94vw;flex-wrap:wrap;justify-content:center}
#dock label{font-size:9px;letter-spacing:.3em;color:var(--amber-dim);font-weight:700;font-family:var(--disp)}
#wslide{-webkit-appearance:none;appearance:none;width:min(300px,38vw);height:6px;border-radius:0;
background:linear-gradient(90deg,#6fa3b5 0%,#ffb454 55%,#ff5d4d 100%);outline:none;cursor:ew-resize}
#wslide::-webkit-slider-thumb{-webkit-appearance:none;width:15px;height:22px;background:#ffd9a0;
border:2px solid #241505;border-radius:2px;cursor:ew-resize;box-shadow:0 0 0 1px #ffb454}
#wslide::-moz-range-thumb{width:13px;height:20px;background:#ffd9a0;border:2px solid #241505;border-radius:2px;cursor:ew-resize}
.btn{font-family:var(--disp);font-weight:800;font-size:10px;letter-spacing:.18em;color:var(--amber);
background:#101b23;border:1px solid #6a4d22;padding:7px 11px;cursor:pointer;border-radius:2px;
transition:background .12s,color .12s,transform .06s}
.btn:hover{background:var(--amber);color:#1a1005}
.btn:active{transform:translateY(1px)}
.btn.on{background:var(--amber);color:#1a1005;box-shadow:0 0 12px #ffb45455}
#keys{width:100%;text-align:center;font-size:9.5px;color:#61788a;letter-spacing:.08em}
#keys b{color:#9fb4c2;font-weight:700}
@media (max-width:640px){ #brand{display:none} #windbox .num{font-size:40px} }
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="hud">
<div class="panel" id="windbox">
<div class="lbl">WIND · LIVE</div>
<div class="num"><span id="wv">0</span><small>km/h</small></div>
<div id="beaufort">Calm</div>
<div class="mrow">GRIP <div class="mbar"><i id="gfill"></i></div></div>
<div class="mrow">FRAME <div class="mbar"><i id="rfill"></i></div></div>
<span id="chip" class="c-set">CANOPY SET</span>
<div id="flavor">a damp stroll.</div>
</div>
<div class="panel" id="brand">
<h1>GALE</h1>
<p>one person · one umbrella · rising wind</p>
<p>umbrellas lost: <span class="lost-ct" id="lostct">0</span></p>
</div>
<div class="panel" id="dock">
<label>WIND</label>
<input id="wslide" type="range" min="0" max="170" step="1" value="8">
<button class="btn on" id="bAuto">AUTO RISE ●</button>
<button class="btn" id="bSq">SQUALL</button>
<button class="btn" id="bNew">NEW UMBRELLA</button>
<button class="btn" id="bSnd">SOUND OFF</button>
<div id="keys"><b>↑/↓</b> wind · <b>A</b> auto · <b>G</b> squall · <b>R</b> new umbrella · <b>M</b> sound</div>
</div>
</div>
<script>
'use strict';
/* ================= helpers ================= */
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const TAU=Math.PI*2;
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const lerp=(a,b,t)=>a+(b-a)*t;
const rnd=(a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
const mixc=(c1,c2,t)=>[lerp(c1[0],c2[0],t),lerp(c1[1],c2[1],t),lerp(c1[2],c2[2],t)];
const rgb=(c,a=1)=>`rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;
function ik(ax,ay,bx,by,l1,l2,side){
let dx=bx-ax,dy=by-ay,d=Math.hypot(dx,dy)||1e-4;
const dc=clamp(d,Math.abs(l1-l2)+.5,l1+l2-.5);
const a1=Math.acos(clamp((l1*l1+dc*dc-l2*l2)/(2*l1*dc),-1,1));
const b=Math.atan2(dy,dx);
return{x:ax+Math.cos(b+a1*side)*l1,y:ay+Math.sin(b+a1*side)*l1,
ex:ax+Math.cos(b)*d,ey:ay+Math.sin(b)*d,short:d>l1+l2-1};
}
function rr(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
/* ================= geometry / scene ================= */
let W=0,H=0,SC=1,GY=0,PX=0;
let skyline=[],grass=[],puddles=[],clouds=[],debris=[];
let cloudSprites=[];
function makeCloudSprites(){
cloudSprites=[];
for(let s=0;s<3;s++){
const c=document.createElement('canvas');c.width=300;c.height=130;
const g=c.getContext('2d');
for(let i=0;i<9;i++){
const x=30+Math.random()*240,y=45+Math.random()*55,r=26+Math.random()*44;
const gr=g.createRadialGradient(x,y,r*0.15,x,y,r);
gr.addColorStop(0,'rgba(255,255,255,0.85)');gr.addColorStop(1,'rgba(255,255,255,0)');
g.fillStyle=gr;g.beginPath();g.arc(x,y,r,0,TAU);g.fill();
}
cloudSprites.push(c);
}
}
function layout(){
W=innerWidth;H=innerHeight;
const DPR=Math.min(2,window.devicePixelRatio||1);
cv.width=W*DPR;cv.height=H*DPR;cv.style.width=W+'px';cv.style.height=H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
SC=clamp(H/900,.55,1.5);GY=H*.84;PX=W*.42;
skyline=[];let x=-20;
while(x<W+40){const w=rnd(34,90)*SC,h=rnd(40,150)*SC;skyline.push({x,w,h});x+=w+rnd(2,14)*SC;}
grass=[];for(let i=0;i<16;i++)grass.push({x:rnd(0,W),b:3+(Math.random()*3|0),ph:rnd(TAU)});
puddles=[{x:PX-190*SC,y:GY+26*SC,rx:95*SC,ry:11*SC},
{x:PX+150*SC,y:GY+44*SC,rx:70*SC,ry:9*SC},
{x:W*.74,y:GY+18*SC,rx:110*SC,ry:12*SC}];
clouds=[];for(let i=0;i<9;i++)clouds.push({x:rnd(W),y:rnd(H*.04,H*.34),s:rnd(.7,1.9),
sp:i%3?rnd(.5,.8):rnd(.2,.35),spr:(Math.random()*3|0),a:rnd(.35,.7)});
debris=[];for(let i=0;i<8;i++)debris.push({x:rnd(W),y:rnd(GY-160,GY-14),vx:0,rot:rnd(TAU),
vr:rnd(-4,4),ph:rnd(TAU),leaf:i<6,sz:rnd(4,8)*SC});
}
addEventListener('resize',layout);
makeCloudSprites();layout();
/* ================= wind ================= */
const wind={target:8,auto:true,t:0,phys:6,disp:6,sqT:0,sqDur:1,sqAmp:0,nextSq:7};
const qAt=km=>(km/72)**2;
function windStep(dt,t){
if(wind.auto){
wind.t+=dt;
wind.target=wind.t<100?8+wind.t*1.3:138+14*Math.sin((wind.t-100)*.05);
slider.value=Math.round(wind.target);
}
wind.nextSq-=dt;
if(wind.nextSq<0&&wind.sqT<=0){wind.sqDur=rnd(2.2,4);wind.sqT=wind.sqDur;
wind.sqAmp=rnd(16,34)*(wind.target>25?1:.4);wind.nextSq=rnd(9,20);}
let sq=0;
if(wind.sqT>0){wind.sqT-=dt;sq=Math.sin(Math.PI*clamp(1-wind.sqT/wind.sqDur,0,1))**1.4*wind.sqAmp;}
const gust=(Math.sin(t*1.9+1.3)*.55+Math.sin(t*3.7+4.2)*.3+Math.sin(t*7.3+2.2)*.15)
*(2+wind.target*.16);
const raw=Math.max(0,wind.target+gust+sq);
wind.phys+=(raw-wind.phys)*(1-Math.exp(-dt/.12));
wind.disp+=(raw-wind.disp)*(1-Math.exp(-dt/.45));
}
const windPx=()=>wind.phys*3*SC;
/* ================= person ================= */
const person={lean:.05,crouch:0,recoil:0,twoHands:false,skid:0,skidT:9};
let pose=null;
function personStep(dt,t,q){
const f=umb.state==='held'?clamp(umb.force/qAt(80),0,1.5):0;
const tL=.05+f*.8+(umb.inv>.5?.12:0)-person.recoil*.55;
person.lean+=(tL-person.lean)*(1-Math.exp(-dt/.28));
const tC=clamp((f-.25)/.75,0,1);
person.crouch+=(tC-person.crouch)*(1-Math.exp(-dt/.35));
person.twoHands=f>.45&&umb.state==='held';
person.recoil*=Math.exp(-dt*2.4);
if(person.skidT<.4)person.skidT+=dt;
const jit=q*1.7*SC*(Math.sin(t*41)+Math.sin(t*29)*.6)*.5*(.3+f);
const stance=(26+28*tC)*SC;
const bob=Math.sin(t*2.2)*1.2*SC*(.3+f);
const footF={x:PX-stance,y:GY},footB={x:PX+stance*.85+person.skid,y:GY};
const hip={x:PX-Math.sin(person.lean)*36*SC+jit*.4,
y:GY-(86-24*person.crouch)*SC+bob};
const tilt=person.lean*.92+.03,torso=62*SC;
const sh={x:hip.x-Math.sin(tilt)*torso,y:hip.y-Math.cos(tilt)*torso};
const neck={x:sh.x-Math.sin(tilt)*6*SC,y:sh.y-Math.cos(tilt)*6*SC};
const headAng=tilt+.12+.3*f;
const head={x:neck.x-Math.sin(headAng)*15*SC,y:neck.y-Math.cos(headAng)*15*SC};
// arms
const a2={x:Math.sin(umb.th),y:-Math.cos(umb.th)};
const reach=(.55+.45*clamp(f,0,1))*64*SC;
let hand,hand2=null,elb,elb2=null,dang;
if(umb.state==='held'){
hand={x:sh.x+a2.x*reach+umb.lurch*20*SC,y:sh.y+a2.y*reach};
const ikA=ik(sh.x,sh.y,hand.x,hand.y,34*SC,32*SC,1);
elb=ikA;dang=Math.atan2(ikA.ey-sh.y,ikA.ex-sh.x);
if(person.twoHands){
hand2={x:hand.x+a2.x*.26*umbLP(),y:hand.y+a2.y*.26*umbLP()};
elb2=ik(sh.x,sh.y,hand2.x,hand2.y,34*SC,32*SC,1);
}
}else{
const swing=Math.sin(t*3)*.1+person.recoil*1.4;
hand={x:sh.x+Math.sin(.5+swing)*30*SC,y:sh.y+Math.cos(.5+swing)*46*SC};
elb=ik(sh.x,sh.y,hand.x,hand.y,34*SC,32*SC,-1);
dang=Math.atan2(hand.y-sh.y,hand.x-sh.x);
}
const kneeF=ik(hip.x,hip.y,footF.x,footF.y,46*SC,44*SC,1);
const kneeB=ik(hip.x,hip.y,footB.x,footB.y,46*SC,44*SC,1);
pose={hip,sh,neck,head,headAng,tilt,footF,footB,kneeF,kneeB,hand,hand2,elb,elb2,dang,f,jit};
// scarf verlet
scarfStep(dt,t,neck);
}
/* scarf */
let scarf=null;
function scarfStep(dt,t,neck){
if(!scarf){scarf=[];for(let i=0;i<9;i++)scarf.push({x:neck.x,y:neck.y+i*6,px:neck.x,py:neck.y+i*6});}
scarf[0].x=neck.x;scarf[0].y=neck.y+2*SC;scarf[0].px=scarf[0].x;scarf[0].py=scarf[0].y;
const wx=windPx()*.85,seg=7*SC;
for(let i=1;i<scarf.length;i++){
const p=scarf[i],vx=(p.x-p.px)*.96,vy=(p.y-p.py)*.96;
p.px=p.x;p.py=p.y;
p.x+=vx+(wx*(1+.4*Math.sin(t*6+i)))*dt*dt*3;
p.y+=vy+(420*SC+Math.sin(t*9+i*2)*60)*dt*dt;
}
for(let k=0;k<3;k++)for(let i=1;i<scarf.length;i++){
const a=scarf[i-1],b=scarf[i];
let dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1e-4;
const off=(d-seg)/d*.5;
if(i>1){a.x+=dx*off;a.y+=dy*off;}
b.x-=dx*off;b.y-=dy*off;
}
}
/* ================= umbrella ================= */
const umb={state:'held',inv:0,invV:0,invT:0,open:1,th:-.15,thV:0,psiOff:0,
grip:.15,overT:0,calmT:0,flyT:0,wrench:null,lurch:0,force:0,flight:null,respawn:0};
let lostCount=0;
const umbLP=()=>168*SC, umbRL=()=>108*SC;
let canopyInfo={x:0,y:0,rx:1,ry:1,on:false};
function startWrench(){
umb.wrench={t:0,dur:rnd(.5,1),dir:Math.random()<.75?1:-1,mag:rnd(.7,1.5)};
}
function doInvert(){
umb.invT=1;person.recoil=Math.max(person.recoil,.5);SND.snap();
flavorSay('INVERTED — it is a sail now.',3);
}
function flyOff(){
const a2={x:Math.sin(umb.th),y:-Math.cos(umb.th)};
const apex={x:pose.hand.x+a2.x*(1-umb.grip)*umbLP(),y:pose.hand.y+a2.y*(1-umb.grip)*umbLP()};
umb.flight={x:apex.x,y:apex.y,vx:windPx()*.8+rnd(60,150)*SC,vy:-rnd(140,280)*SC,
th:umb.th,vth:rnd(2.4,5)*(Math.random()<.5?-1:1)+2.2,rest:false};
umb.state='lost';umb.respawn=4.5;person.recoil=1;person.skidT=0;person.skid=0;
lostCount++;el.lostct.textContent=lostCount;
SND.whoosh();flavorSay('it\u2019s gone. watch it tumble.',3);
}
function newUmbrella(){
umb.state='held';umb.inv=0;umb.invV=0;umb.invT=0;umb.open=0;umb.th=-.2;umb.thV=0;
umb.psiOff=0;umb.wrench=null;umb.lurch=0;umb.flyT=0;umb.overT=0;umb.calmT=0;umb.flight=null;
flavorSay('fresh umbrella. for now.',2.5);
}
function umbStep(dt,t){
const Wk=wind.phys,q=qAt(Wk);
if(umb.state==='lost'){
const fl=umb.flight;
if(fl&&!fl.rest){
const sEff=umb.inv;
fl.vx+=((windPx()*1.05-fl.vx)*(sEff>.5?2.6:1.1))*dt;
fl.vy+=900*SC*dt;
if(sEff>.5)fl.vy-=Math.min(q*1500*SC,fl.vy+400)*dt*.9;
fl.x+=fl.vx*dt;fl.y+=fl.vy*dt;
fl.th+=fl.vth*dt;fl.vth*=Math.exp(-.25*dt);
if(fl.y>GY-12*SC&&fl.vy>0){
fl.y=GY-12*SC;fl.vy*=-.32;fl.vx*=.8;fl.vth*=.65;
if(Math.abs(fl.vy)<50*SC){fl.rest=true;fl.vy=0;}
}
if(fl.x>W+340)umb.respawn=Math.min(umb.respawn,.6);
}else if(fl){fl.vx*=Math.exp(-dt*2);fl.x+=fl.vx*dt;}
umb.respawn-=dt;
if(umb.respawn<=0)newUmbrella();
canopyInfo.on=false;
return;
}
umb.open=Math.min(1,umb.open+dt/.5);
// wrench scheduler
if(!umb.wrench&&q>qAt(48)&&Math.random()<dt*(q*2.4-.95))startWrench();
if(umb.wrench){
const w=umb.wrench;w.t+=dt;
const p=w.t/w.dur;
if(p>=1){umb.wrench=null;}
else{const env=Math.sin(Math.PI*p);
umb.lurch=w.dir*w.mag*env;
umb.psiOff=w.dir*w.mag*1.45*env;}
}else{
umb.lurch*=Math.exp(-dt*6);
umb.psiOff+=(0-umb.psiOff)*(1-Math.exp(-dt/.35));
}
// pole dynamics
const wn=clamp(Wk/90,0,1);
let aim=-(0.12+.62*wn)+umb.lurch*.35+umb.inv*.45;
const kick=(Math.sin(t*13.7)*.6+Math.sin(t*29.3)*.4)*q*.9;
umb.thV+=((aim-umb.th)*26-umb.thV*7+kick)*dt;
umb.th+=umb.thV*dt;
// aero force
const flut=(Math.sin(t*29)+Math.sin(t*17.3)*.5)*.5;
const attack=Math.max(0,-Math.sin(umb.th));
let F;
if(umb.inv<.5)F=q*(.22+.95*Math.pow(attack,1.4))*(1+.2*flut*q);
else F=q*(1.05+.5*attack)*1.15*(1+.15*flut*q);
umb.force=F;
umb.grip=lerp(.14,.42,clamp(F/qAt(80),0,1));
// inversion trip
const capJit=.9+.2*(Math.sin(t*.23)*.5+Math.sin(t*.071)*.5+.5)*.5;
const cap=qAt(71)*(1-.3*Math.abs(umb.lurch))*capJit;
if(umb.invT===0){
if(F>cap)umb.overT+=dt;else umb.overT=Math.max(0,umb.overT-dt*2);
if(umb.overT>.22||F>cap*1.3)doInvert();
}else{
if(Wk<30){umb.calmT+=dt;
if(umb.calmT>1.2){umb.invT=0;umb.calmT=0;person.recoil=Math.max(person.recoil,.4);
SND.snap();flavorSay('snapped it back. keep moving.',2.5);}
}else umb.calmT=0;
}
// inversion spring
umb.invV+=(umb.invT-umb.inv)*260*dt;umb.invV/= (1+14*dt);
umb.inv=clamp(umb.inv+umb.invV*dt,-.08,1.12);
// fly-off
const gc=qAt(104)*(umb.inv>.5?.72:1)*(person.twoHands?1.18:1)*(1-.25*Math.abs(umb.lurch));
const gripLoad=F*(1+Math.abs(umb.lurch)*.6);
umb.gripRatio=clamp(gripLoad/gc,0,1.4);umb.frameRatio=clamp(F/cap,0,1.4);
if(gripLoad>gc)umb.flyT+=dt;else umb.flyT=Math.max(0,umb.flyT-dt*1.5);
if(umb.flyT>.35||gripLoad>gc*1.5)flyOff();
// canopy screen info for rain occlusion
const a2={x:Math.sin(umb.th),y:-Math.cos(umb.th)};
const apex={x:pose.hand.x+a2.x*(1-umb.grip)*umbLP(),y:pose.hand.y+a2.y*(1-umb.grip)*umbLP()};
const alpha=lerp(1.82,0.92,clamp(umb.inv,0,1));
canopyInfo={x:apex.x+a2.x*Math.cos(alpha)*umbRL()*.5,
y:apex.y+a2.y*Math.cos(alpha)*umbRL()*.5,
rx:umbRL()*1.02,ry:umbRL()*(.34+.28*Math.abs(Math.cos(alpha)))+6*SC,on:umb.open>.6};
}
/* ================= rain / fx ================= */
let drops=[],splashes=[],ripples=[],drips=[];
let flash=0,bolt=null,boltT=0;
function rainStep(dt,t){
const wp=windPx(),target=240+280*clamp((wind.phys-4)/60,0,1);
while(drops.length<target)drops.push({x:rnd(-200,W),y:rnd(-H,GY),vy:rnd(320,520)*SC,layer:Math.random()<.5?0:1});
if(drops.length>target+40)drops.length=target|0;
const ci=canopyInfo;
for(const d of drops){
d.x+=(wp*.9)*dt;d.y+=d.vy*dt;
if(ci.on){
const dx=(d.x-ci.x)/ci.rx,dy=(d.y-ci.y)/ci.ry;
if(dx*dx+dy*dy<1&&d.y<ci.y+ci.ry*.3){
if(Math.random()<(umb.inv>.5?.14:.05))
drips.push({x:ci.x+rnd(-ci.rx,ci.rx)*.9,y:ci.y+ci.ry*.5,vx:wp*.4,vy:rnd(20,80)*SC});
d.y=rnd(-120,-20);d.x=rnd(-260,W)-wp*.25;continue;
}
}
if(d.y>GY){
if(Math.random()<.4&&splashes.length<150)splashes.push({x:d.x,y:GY+rnd(0,26*SC),t:0});
for(const p of puddles)if(Math.abs(d.x-p.x)<p.rx&&Math.random()<.25&&ripples.length<70)
ripples.push({x:d.x,y:p.y+rnd(-p.ry,p.ry)*.6,t:0,p});
d.y=rnd(-120,-20);d.x=rnd(-260,W)-wp*.25;
}
}
for(let i=drips.length-1;i>=0;i--){const p=drips[i];p.vy+=900*SC*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;
if(p.y>GY)drips.splice(i,1);}
for(let i=splashes.length-1;i>=0;i--){splashes[i].t+=dt;if(splashes[i].t>.34)splashes.splice(i,1);}
for(let i=ripples.length-1;i>=0;i--){ripples[i].t+=dt;if(ripples[i].t>.8)ripples.splice(i,1);}
// debris
for(const b of debris){
b.vx+=(wp*1.1-b.vx)*dt*2.2;
b.x+=b.vx*dt;b.ph+=dt*3;
b.y+=Math.sin(b.ph)*30*SC*dt+(b.leaf?14:6)*SC*dt;
b.rot+=b.vr*dt*(1+qAt(wind.phys));
if(b.x>W+30){b.x=-30;b.y=rnd(GY-170,GY-12);}
if(b.y>GY-4){b.y=GY-4;b.ph=rnd(TAU);}
}
// lightning
if(wind.phys>95&&Math.random()<dt*.05){
flash=1;boltT=.14;
bolt=[];let bx=rnd(W*.15,W*.85),by=0;
while(by<GY-60*SC){bolt.push({x:bx,y:by});bx+=rnd(-34,34)*SC;by+=rnd(24,54)*SC;}
SND.thunder();
}
flash*=Math.exp(-dt*7);if(boltT>0)boltT-=dt;
}
/* ================= audio ================= */
const SND={ac:null,on:false,
init(){if(this.ac)return;const A=window.AudioContext||window.webkitAudioContext;if(!A)return;
const ac=this.ac=new A();
const len=ac.sampleRate*2,buf=ac.createBuffer(1,len,ac.sampleRate),ch=buf.getChannelData(0);
for(let i=0;i<len;i++)ch[i]=Math.random()*2-1;
this.buf=buf;
const src=ac.createBufferSource();src.buffer=buf;src.loop=true;
this.f=ac.createBiquadFilter();this.f.type='bandpass';this.f.frequency.value=340;this.f.Q.value=.7;
this.g=ac.createGain();this.g.gain.value=0;
this.master=ac.createGain();this.master.gain.value=.7;
src.connect(this.f);this.f.connect(this.g);this.g.connect(this.master);this.master.connect(ac.destination);
src.start();},
set(on){this.on=on;if(on){this.init();this.ac&&this.ac.resume();}else this.ac&&this.ac.suspend();},
frame(){if(!this.ac||!this.on)return;const n=this.ac.currentTime;
this.g.gain.setTargetAtTime(Math.min(.5,Math.pow(wind.phys/140,1.5)*.55),n,.15);
this.f.frequency.setTargetAtTime(220+wind.phys*7+Math.abs(umb.lurch)*900,n,.2);},
burst(freq,dur,vol){if(!this.ac||!this.on)return;const ac=this.ac,n=ac.currentTime;
const s=ac.createBufferSource();s.buffer=this.buf;
const f=ac.createBiquadFilter();f.type='bandpass';f.frequency.value=freq;f.Q.value=1.2;
const g=ac.createGain();g.gain.setValueAtTime(vol,n);g.gain.exponentialRampToValueAtTime(.001,n+dur);
s.connect(f);f.connect(g);g.connect(this.master);s.start(n);s.stop(n+dur+.05);},
snap(){this.burst(1700,.13,.5);this.burst(240,.2,.4);},
whoosh(){this.burst(700,.6,.45);},
creak(){this.burst(300,.16,.16);},
thunder(){this.burst(110,1.1,.5);}};
/* ================= drawing ================= */
function drawSky(t){
const d=clamp((wind.phys-15)/95,0,1);
const top=mixc([127,149,163],[17,25,34],d),
mid=mixc([169,183,179],[44,58,66],d),
hor=mixc([217,196,143],[101,116,102],d);
const g=ctx.createLinearGradient(0,0,0,GY);
g.addColorStop(0,rgb(top));g.addColorStop(.62,rgb(mid));g.addColorStop(1,rgb(hor));
ctx.fillStyle=g;ctx.fillRect(0,0,W,GY+2);
// clouds
const wp=windPx();
for(const c of clouds){
c.x+=(10+wp*c.sp)*lastDt;
if(c.x>W+200)c.x=-260;
ctx.globalAlpha=c.a*(.45+.5*d);
ctx.filter='none';
ctx.drawImage(cloudSprites[c.spr],c.x,c.y,300*c.s,130*c.s);
ctx.globalAlpha=1;
}
ctx.fillStyle=rgb(mixc([70,86,96],[22,30,38],d),.85);
for(const b of skyline)ctx.fillRect(b.x,GY-b.h,b.w,b.h);
// lightning
if(boltT>0&&bolt){
ctx.strokeStyle=`rgba(235,242,255,${clamp(boltT/.14,0,1)*.9})`;ctx.lineWidth=2.2;
ctx.beginPath();ctx.moveTo(bolt[0].x,bolt[0].y);
for(const p of bolt)ctx.lineTo(p.x,p.y);ctx.stroke();
}
if(flash>0.02){ctx.fillStyle=`rgba(220,232,255,${flash*.4})`;ctx.fillRect(0,0,W,H);}
}
function drawGround(t){
const d=clamp((wind.phys-15)/95,0,1);
const g=ctx.createLinearGradient(0,GY,0,H);
g.addColorStop(0,rgb(mixc([44,58,68],[24,32,40],d)));
g.addColorStop(1,rgb(mixc([30,40,50],[14,20,26],d)));
ctx.fillStyle=g;ctx.fillRect(0,GY,W,H-GY);
ctx.strokeStyle='rgba(255,255,255,.06)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(0,GY+.5);ctx.lineTo(W,GY+.5);ctx.stroke();
// puddles
for(const p of puddles){
ctx.fillStyle=rgb(mixc([90,110,120],[40,54,64],d),.8);
ctx.beginPath();ctx.ellipse(p.x,p.y,p.rx,p.ry,0,0,TAU);ctx.fill();
ctx.fillStyle=`rgba(255,220,160,${.05+.06*d})`;
ctx.beginPath();ctx.ellipse(p.x+p.rx*.2,p.y,p.rx*.3,p.ry*.5,0,0,TAU);ctx.fill();
}
for(const r of ripples){
const k=r.t/.8;
ctx.strokeStyle=`rgba(210,230,240,${(1-k)*.4})`;ctx.lineWidth=1;
ctx.beginPath();ctx.ellipse(r.x,r.y,r.p.rx*.12+k*26*SC,r.p.ry*.12+k*5*SC,0,0,TAU);ctx.stroke();
}
// grass
const wp=windPx();
ctx.strokeStyle=rgb(mixc([70,96,66],[38,54,42],d));ctx.lineWidth=1.6*SC;
for(const tuft of grass){
for(let b=0;b<tuft.b;b++){
const hgt=(12+b*4)*SC,sway=clamp(wp*.055,0,26*SC)*(1+.35*Math.sin(t*7+tuft.ph+b));
ctx.beginPath();ctx.moveTo(tuft.x+b*3*SC,GY+2);
ctx.quadraticCurveTo(tuft.x+b*3*SC+sway*.4,GY-hgt*.6,
tuft.x+b*3*SC+sway+Math.sin(t*11+tuft.ph+b)*3*SC,GY-hgt);
ctx.stroke();
}
}
}
function drawLamp(t){
const d=clamp((wind.phys-15)/95,0,1);
const bx=W*.78,by=GY,hgt=300*SC;
const bend=clamp(windPx()*.09,0,80*SC)+Math.sin(t*3.1)*qAt(wind.phys)*8*SC;
const tx=bx+bend,ty=by-hgt+Math.abs(bend)*.12;
ctx.strokeStyle='#11181e';ctx.lineWidth=6*SC;
ctx.beginPath();ctx.moveTo(bx,by);ctx.quadraticCurveTo(bx+bend*.25,by-hgt*.6,tx,ty);ctx.stroke();
ctx.fillStyle='#0d1318';ctx.fillRect(bx-7*SC,by-8*SC,14*SC,8*SC);
// head
ctx.save();ctx.translate(tx,ty);ctx.rotate(bend/hgt*.8);
ctx.fillStyle='#151d24';rr(-14*SC,-8*SC,28*SC,10*SC,3*SC);ctx.fill();
ctx.fillStyle=`rgba(255,214,140,${.55+.4*d+Math.sin(t*17)*.05*d})`;
ctx.fillRect(-10*SC,-1*SC,20*SC,4*SC);
ctx.restore();
if(d>.25){
const g=ctx.createRadialGradient(tx,ty+6*SC,4,tx,ty+6*SC,150*SC);
g.addColorStop(0,`rgba(255,205,130,${.22*d})`);g.addColorStop(1,'rgba(255,205,130,0)');
ctx.fillStyle=g;
ctx.beginPath();ctx.moveTo(tx-8*SC,ty+4*SC);ctx.lineTo(tx-70*SC,GY+8*SC);
ctx.lineTo(tx+78*SC,GY+8*SC);ctx.lineTo(tx+10*SC,ty+4*SC);ctx.closePath();ctx.fill();
}
}
function drawRain(layer){
const wp=windPx();
ctx.strokeStyle=`rgba(190,214,228,${layer?0.42:0.22})`;
ctx.lineWidth=layer?1.3:1;
ctx.beginPath();
for(const d of drops){
if(d.layer!==layer)continue;
const l=clamp(Math.hypot(wp*.9,d.vy)*.045,6*SC,34*SC);
const nx=(wp*.9)/Math.hypot(wp*.9,d.vy),ny=d.vy/Math.hypot(wp*.9,d.vy);
ctx.moveTo(d.x,d.y);ctx.lineTo(d.x-nx*l,d.y-ny*l);
}
ctx.stroke();
if(layer){
ctx.fillStyle='rgba(200,224,238,.5)';
for(const p of drips){ctx.fillRect(p.x,p.y,1.4,4*SC);}
ctx.strokeStyle='rgba(200,224,238,.45)';ctx.lineWidth=1;
for(const s of splashes){
const k=s.t/.34;
ctx.beginPath();ctx.ellipse(s.x,s.y,3+k*11*SC,(3+k*11*SC)*.3,0,Math.PI,TAU);ctx.stroke();
}
}
}
function drawDebris(){
for(const b of debris){
ctx.save();ctx.translate(b.x,b.y);ctx.rotate(b.rot);
if(b.leaf){ctx.fillStyle='#6d5a2c';
ctx.beginPath();ctx.ellipse(0,0,b.sz,b.sz*.45,0,0,TAU);ctx.fill();}
else{ctx.fillStyle='#cfd6d2';ctx.globalAlpha=.85;
ctx.fillRect(-b.sz*.7,-b.sz*.5,b.sz*1.4,b.sz);ctx.globalAlpha=1;}
ctx.restore();
}
}
function limb(p1,p2,p3,w,col){
ctx.strokeStyle=col;ctx.lineWidth=w;ctx.lineCap='round';ctx.lineJoin='round';
ctx.beginPath();ctx.moveTo(p1.x,p1.y);
if(p3)ctx.quadraticCurveTo(p3.x,p3.y,p2.x,p2.y);else ctx.lineTo(p2.x,p2.y);
ctx.stroke();
}
function drawPerson(t){
const p=pose,S=SC;
const coat='#f0b429',coatD='#c9901c',pant='#263642',boot='#14181d',skin='#e8b48c';
// back leg
limb(p.hip,p.footB,p.kneeB,9*S,'#1d2a35');
ctx.fillStyle=boot;rr(p.footB.x-4*S,p.footB.y-7*S,17*S,7*S,2*S);ctx.fill();
// front leg
limb(p.hip,p.footF,p.kneeF,9*S,pant);
ctx.fillStyle=boot;rr(p.footF.x-11*S,p.footF.y-7*S,17*S,7*S,2*S);ctx.fill();
// coat body
const tilt=p.tilt,hw=15*S,hemY=p.hip.y+20*S;
const flapA=clamp(windPx()*.05,2*S,26*S),fl=Math.sin(t*13)*flapA*.5+flapA;
ctx.fillStyle=coat;
ctx.beginPath();
ctx.moveTo(p.sh.x-hw,p.sh.y+4*S);
ctx.lineTo(p.sh.x+hw,p.sh.y+4*S);
ctx.quadraticCurveTo(p.hip.x+hw+6*S,p.hip.y,p.hip.x+hw+10*S+fl*.6,hemY);
ctx.quadraticCurveTo(p.hip.x+hw*.4+fl*.5,hemY+4*S+Math.sin(t*15)*3*S,
p.hip.x-hw*.2+fl,hemY+2*S);
ctx.quadraticCurveTo(p.hip.x-hw-4*S+fl*.3,hemY-2*S,p.hip.x-hw-2*S,p.hip.y);
ctx.closePath();ctx.fill();
ctx.fillStyle=coatD;
ctx.beginPath();ctx.moveTo(p.sh.x+hw,p.sh.y+4*S);
ctx.quadraticCurveTo(p.hip.x+hw+6*S,p.hip.y,p.hip.x+hw+10*S+fl*.6,hemY);
ctx.lineTo(p.hip.x+hw*.4,hemY);ctx.closePath();ctx.globalAlpha=.5;ctx.fill();ctx.globalAlpha=1;
// far arm
if(umb.state==='held'){
const a2={x:Math.sin(umb.th),y:-Math.cos(umb.th)};
const fh={x:p.hand.x+a2.x*6*S,y:p.hand.y+a2.y*6*S};
limb(p.sh,fh,{x:(p.sh.x+fh.x)/2+8*S,y:(p.sh.y+fh.y)/2+8*S},8*S,coatD);
}
// head + hood
const hoodUp=wind.phys>18;
ctx.fillStyle=skin;
ctx.beginPath();ctx.arc(p.head.x,p.head.y,11*S,0,TAU);ctx.fill();
ctx.fillStyle='#20242a';
ctx.beginPath();ctx.arc(p.head.x-6*S,p.head.y-2*S,1.5*S,0,TAU);ctx.fill();
if(hoodUp){
ctx.fillStyle=coat;
ctx.beginPath();
ctx.arc(p.head.x,p.head.y,14.5*S,p.headAng-Math.PI*.45,p.headAng+Math.PI*.85);
ctx.quadraticCurveTo(p.head.x+Math.sin(p.headAng)*4*S,p.head.y+Math.cos(p.headAng)*4*S,
p.head.x-Math.sin(p.headAng-Math.PI*.45)*14.5*S,p.head.y-Math.cos(p.headAng-Math.PI*.45)*14.5*S);
ctx.closePath();ctx.fill();
ctx.fillStyle=coatD;
ctx.beginPath();ctx.arc(p.head.x,p.head.y,14.5*S,p.headAng+Math.PI*.5,p.headAng+Math.PI*.85);
ctx.lineTo(p.head.x,p.head.y);ctx.closePath();ctx.globalAlpha=.5;ctx.fill();ctx.globalAlpha=1;
}else{
ctx.strokeStyle='#3a2c1c';ctx.lineWidth=2*S;
for(let i=0;i<4;i++){
const hx=p.head.x+6*S,hy=p.head.y-8*S+i*3*S,fl2=clamp(windPx()*.06,2,20*S);
ctx.beginPath();ctx.moveTo(hx,hy);
ctx.quadraticCurveTo(hx+8*S,hy-3*S+Math.sin(t*14+i)*3*S,hx+12*S+fl2,hy+Math.sin(t*12+i*2)*4*S);
ctx.stroke();
}
}
// scarf
if(scarf){
ctx.strokeStyle='#2e6f6a';ctx.lineCap='round';
for(let i=1;i<scarf.length;i++){
ctx.lineWidth=(6.5-i*.55)*S;
ctx.beginPath();ctx.moveTo(scarf[i-1].x,scarf[i-1].y);ctx.lineTo(scarf[i].x,scarf[i].y);ctx.stroke();
}
}
}
function drawNearArm(){
const p=pose,S=SC;
if(!p)return;
if(umb.state==='held'){
limb(p.sh,p.hand,p.elb,8.5*SC,'#f0b429');
if(p.hand2&&p.elb2)limb(p.sh,p.hand2,p.elb2,8*SC,'#e2a822');
}else{
limb(p.sh,p.hand,p.elb,8.5*SC,'#f0b429');
}
}
function drawHands(){
const p=pose;if(!p||umb.state!=='held')return;
ctx.fillStyle='#d8a06e';
ctx.beginPath();ctx.arc(p.hand.x,p.hand.y,5*SC,0,TAU);ctx.fill();
if(p.hand2){ctx.beginPath();ctx.arc(p.hand2.x,p.hand2.y,4.6*SC,0,TAU);ctx.fill();}
}
/* umbrella renderer */
function ribPt(apex,th,phi,alpha,r,flex,wf){
const ca=Math.cos(alpha),sa=Math.sin(alpha);
const dx=Math.sin(th)*ca+Math.cos(th)*Math.cos(phi)*sa;
const dy=-Math.cos(th)*ca+Math.sin(th)*Math.cos(phi)*sa;
const z=Math.sin(phi)*sa;
return{x:apex.x+dx*r+z*r*.42*SC+flex*wf,y:apex.y+dy*r+z*r*(-.16*SC)+flex*wf*.18,z};
}
function drawUmbrella(base,th,psi,inv,open,flexPx,t){
const LP=umbLP(),RL=umbRL();
const oe=1+2.7*Math.pow(open-1,3)+1.70158*Math.pow(open-1,2);
const poleLen=LP*(.35+.65*open),ribLen=RL*Math.max(.05,oe);
const a2={x:Math.sin(th),y:-Math.cos(th)};
const apex={x:base.x+a2.x*poleLen,y:base.y+a2.y*poleLen};
const alpha=lerp(1.82,.92,clamp(inv,0,1.1))+Math.sin(t*31)*.03*qAt(wind.phys);
const N=8,pts=[];
for(let i=0;i<N;i++){
const phi=psi+i*TAU/N;
const wf=.5+.5*Math.cos(phi+th)*Math.cos(th);
pts.push({phi,wf,
tip:ribPt(apex,th,phi,alpha,ribLen,flexPx,wf),
mid:ribPt(apex,th,phi,alpha*.62,ribLen*.55,flexPx*.4,wf)});
}
// pole (slight bend under load)
const pm={x:(base.x+apex.x)/2+flexPx*.15,y:(base.y+apex.y)/2};
ctx.strokeStyle='#c3cad2';ctx.lineWidth=3*SC;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(base.x,base.y);ctx.quadraticCurveTo(pm.x,pm.y,apex.x,apex.y);ctx.stroke();
// handle hook
ctx.strokeStyle='#5a4632';ctx.lineWidth=3.4*SC;
ctx.beginPath();ctx.moveTo(base.x,base.y);
ctx.arc(base.x+5*SC,base.y+8*SC,8*SC,-Math.PI*.5,Math.PI*.7);ctx.stroke();
// panels + ribs, painter-sorted
const items=[];
for(let i=0;i<N;i++){
const a=pts[i],b=pts[(i+1)%N];
items.push({z:(a.tip.z+b.tip.z)/2,a,b});
}
items.sort((u,v)=>u.z-v.z);
const sat=72-46*clamp(inv,0,1),lig=40+24*clamp(inv,0,1);
for(const it of items){
const sh=(it.z+1)*5;
ctx.fillStyle=`hsl(4,${sat}%,${lig+sh}%)`;
ctx.beginPath();
ctx.moveTo(apex.x,apex.y);
ctx.quadraticCurveTo(it.a.mid.x,it.a.mid.y,it.a.tip.x,it.a.tip.y);
const sc={x:(it.a.tip.x+it.b.tip.x)/2+(apex.x-(it.a.tip.x+it.b.tip.x)/2)*.16,
y:(it.a.tip.y+it.b.tip.y)/2+(apex.y-(it.a.tip.y+it.b.tip.y)/2)*.16};
ctx.quadraticCurveTo(sc.x,sc.y,it.b.tip.x,it.b.tip.y);
ctx.quadraticCurveTo(it.b.mid.x,it.b.mid.y,apex.x,apex.y);
ctx.closePath();ctx.fill();
ctx.strokeStyle=`hsl(4,${sat}%,${lig*.62}%)`;ctx.lineWidth=1.1*SC;ctx.stroke();
}
ctx.strokeStyle='#3a2228';ctx.lineWidth=1.6*SC;
for(const it of items){
ctx.beginPath();ctx.moveTo(apex.x,apex.y);
ctx.quadraticCurveTo(it.a.mid.x,it.a.mid.y,it.a.tip.x,it.a.tip.y);ctx.stroke();
}
// ferrule
ctx.strokeStyle='#c3cad2';ctx.lineWidth=2.4*SC;
ctx.beginPath();ctx.moveTo(apex.x,apex.y);ctx.lineTo(apex.x+a2.x*10*SC,apex.y+a2.y*10*SC);ctx.stroke();
ctx.fillStyle='#c3cad2';ctx.beginPath();ctx.arc(apex.x+a2.x*11*SC,apex.y+a2.y*11*SC,2.2*SC,0,TAU);ctx.fill();
}
function drawUmbrellaHeld(t){
if(umb.state!=='held'||!pose)return;
const a2={x:Math.sin(umb.th),y:-Math.cos(umb.th)};
const base={x:pose.hand.x-a2.x*umb.grip*umbLP(),y:pose.hand.y-a2.y*umb.grip*umbLP()};
const q=qAt(wind.phys);
const flexPx=q*44*SC*(1+.5*umb.inv)*(1+.3*Math.sin(t*23))+Math.abs(umb.lurch)*22*SC;
drawUmbrella(base,umb.th,umb.psiOff,umb.inv,umb.open,flexPx,t);
}
function drawUmbrellaFlight(t){
const fl=umb.flight;if(!fl)return;
const base={x:fl.x-Math.sin(fl.th)*umbLP()*.5,y:fl.y+Math.cos(fl.th)*umbLP()*.5};
drawUmbrella(base,fl.th,fl.th*.5,umb.inv,1,qAt(wind.phys)*40*SC,t);
}
/* ================= HUD ================= */
const el={};['wv','beaufort','chip','flavor','gfill','rfill','wslide','bAuto','bSq','bNew','bSnd','lostct']
.forEach(id=>el[id]=document.getElementById(id));
const slider=el.wslide;
function beaufort(v){
if(v<1)return'Calm';if(v<6)return'Light air';if(v<12)return'Light breeze';
if(v<20)return'Gentle breeze';if(v<29)return'Moderate breeze';if(v<39)return'Fresh breeze';
if(v<50)return'Strong breeze';if(v<62)return'Near gale';if(v<75)return'GALE';
if(v<89)return'SEVERE GALE';if(v<103)return'STORM';if(v<118)return'VIOLENT STORM';
return'HURRICANE-FORCE';
}
let flavorTxt='',flavorHold=0,cache={};
function flavorSay(s,hold){flavorTxt=s;flavorHold=hold;}
function hudStep(dt){
const wv=Math.round(wind.disp);
if(cache.wv!==wv){cache.wv=wv;el.wv.textContent=wv;}
const bf=beaufort(wind.disp);
if(cache.bf!==bf){cache.bf=bf;el.beaufort.textContent=bf;}
let chipTxt,chipCls;
if(umb.state==='lost'){chipTxt='CANOPY AIRBORNE';chipCls='c-lost';}
else if(umb.inv>.5){chipTxt='CANOPY INVERTED';chipCls='c-inv';}
else if(umb.frameRatio>.55){chipTxt='FRAME LOADING';chipCls='c-flex';}
else{chipTxt='CANOPY SET';chipCls='c-set';}
if(cache.chip!==chipTxt){cache.chip=chipTxt;el.chip.textContent=chipTxt;el.chip.className=chipCls;}
const g=umb.state==='held'?clamp(1-(umb.gripRatio||0),0,1):0;
el.gfill.style.width=(g*100).toFixed(1)+'%';
el.gfill.style.background=g>.5?'#9fd8a6':g>.25?'#ffb454':'#ff5d4d';
const r=umb.state==='held'?clamp(umb.frameRatio||0,0,1):0;
el.rfill.style.width=(r*100).toFixed(1)+'%';
el.rfill.style.background=r<.55?'#9fd8a6':r<.9?'#ffb454':'#ff5d4d';
if(flavorHold>0){flavorHold-=dt;}
else{
const Wk=wind.disp;
flavorTxt=umb.state==='lost'?'somewhere over the rooftops…'
:umb.inv>.5?'wrestle it down or let go.'
:Wk<15?'a damp stroll.'
:Wk<35?'the canopy starts to talk.'
:Wk<55?'ribs creak. both hands.'
:Wk<75?'leaning hard. heels dug in.'
:'the frame is loading up—';
}
if(cache.fl!==flavorTxt){cache.fl=flavorTxt;el.flavor.textContent=flavorTxt;}
}
/* ================= input ================= */
function setManual(v){wind.auto=false;wind.target=clamp(v,0,170);
slider.value=Math.round(wind.target);el.bAuto.classList.remove('on');}
function setAuto(on){wind.auto=on;el.bAuto.classList.toggle('on',on);
if(on)wind.t=clamp((wind.target-8)/1.3,0,100);}
slider.addEventListener('input',()=>setManual(+slider.value));
el.bAuto.addEventListener('click',()=>setAuto(!wind.auto));
el.bSq.addEventListener('click',()=>{if(wind.sqT<=0){wind.sqDur=rnd(2.4,4);wind.sqT=wind.sqDur;
wind.sqAmp=rnd(20,38);}});
el.bNew.addEventListener('click',newUmbrella);
el.bSnd.addEventListener('click',()=>{SND.set(!SND.on);
el.bSnd.textContent=SND.on?'SOUND ON':'SOUND OFF';el.bSnd.classList.toggle('on',SND.on);});
addEventListener('keydown',e=>{
const k=e.key;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(k))e.preventDefault();
if(k==='ArrowUp'||k==='ArrowRight'||k==='w')setManual(wind.target+4);
else if(k==='ArrowDown'||k==='ArrowLeft'||k==='s')setManual(wind.target-4);
else if(k==='a'||k==='A')setAuto(!wind.auto);
else if(k==='g'||k==='G')el.bSq.click();
else if(k==='r'||k==='R')newUmbrella();
else if(k==='m'||k==='M')el.bSnd.click();
});
/* ================= main loop ================= */
let last=performance.now(),lastDt=.016,T=0,creakCool=0;
function frame(now){
requestAnimationFrame(frame);
let dt=(now-last)/1000;last=now;dt=clamp(dt,.001,.05);lastDt=dt;T+=dt;
windStep(dt,T);
const q=qAt(wind.phys);
personStep(dt,T,q);
umbStep(dt,T);
rainStep(dt,T);
SND.frame();
if(umb.state==='held'&&(umb.frameRatio||0)>.7){
creakCool-=dt;if(creakCool<=0){SND.creak();creakCool=.9+Math.random();}
}
// ---- render ----
const shake=q>1?(q-1)*2.2:0;
ctx.save();
if(shake>0)ctx.translate((Math.sin(T*47)+Math.sin(T*31))*.5*shake,(Math.sin(T*39))*.4*shake);
drawSky(T);
drawGround(T);
drawLamp(T);
drawRain(0);
drawPerson(T);
drawUmbrellaHeld(T);
drawNearArm();
drawHands();
drawUmbrellaFlight(T);
drawDebris();
drawRain(1);
// vignette
const vg=ctx.createRadialGradient(W*.5,H*.45,H*.3,W*.5,H*.5,H*.85);
vg.addColorStop(0,'rgba(5,10,14,0)');vg.addColorStop(1,'rgba(5,10,14,.42)');
ctx.fillStyle=vg;ctx.fillRect(0,0,W,H);
ctx.restore();
hudStep(dt);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
Where it looks right. The wind reads honestly: gusts arrive in waves, the readout, the grass, the scarf, the rain angle and the lamp bend all agree with each other at every moment. The umbrella tilts into the wind progressively, the ribs visibly load and flutter, the holder chokes up the pole, brings in the second hand, widens the stance, crouches, and leans until the coat hem is streaming. Inversion is a real snap — through-flat, tips whipping past the apex, lining color flipping — and it usually follows a sideways wrench, which is exactly how it feels in real life. Once inverted, the thing becomes a sail: the person gets hauled forward, the grip meter collapses, and the umbrella leaves at speed, tumbles, catches air, and skids. Rain shadows under the canopy, drips off the rim, splashes, puddle ripples, and the dry patch all track the geometry.
Where it looks wrong, before you find it. (1) Real inversion is asymmetric — one or two ribs pop first and the rest follow; mine flips the whole dome at once. (2) The sideways wrench is faked as a rotation of the rib ring in an oblique projection; a true side load would tilt the pole out of the picture plane, which a side view can't honestly show. (3) The person's feet never skid or re-step; real people shuffle, hop, and get dragged — mine is welded to the pavement per your rules, so extreme wind looks like gymnastics rather than defeat. (4) The fly-off impulse is theatrical (up and away); a real gale often takes an umbrella almost horizontally and low. (5) Rain is drawn as streaks with a fixed aspect, so at hurricane force it starts to look like laser fire rather than water. (6) The canopy never suffers partial failure — no rib snapping, no fabric tearing; it only inverts or departs. (7) Re-snap-back in lull wind is too easy and too clean; a real inverted umbrella wants two hands and an argument.