| 1 | // The companion's little cove. It uses the existing animation clock and stops |
| 2 | // with reduced motion / page visibility; it never changes Engine presence. |
| 3 | (function(root){ |
| 4 | 'use strict'; |
| 5 | const R=root.WhaleRig,TAU=Math.PI*2; |
| 6 | let target={x:0,y:0},gaze={x:0,y:0},lastFrame=0; |
| 7 | const ripples=[]; |
| 8 | function observe(x,y){target={x:R.clamp(x/62,-1,1),y:R.clamp(y/62,-1,1)};} |
| 9 | function leave(){target={x:0,y:0};} |
| 10 | function tap(x,y,d){ |
| 11 | const point={x:R.clamp(x,-42,42),y:R.clamp(y,25,48),at:d.f}; |
| 12 | ripples.push(point);if(ripples.length>3)ripples.shift();observe(point.x,point.y); |
| 13 | } |
| 14 | function pose(d){ |
| 15 | const dt=Math.max(0,Math.min(.25,(d.f-lastFrame)/30));lastFrame=d.f; |
| 16 | if(d.reduced){gaze={x:0,y:0};return {};} |
| 17 | const active=d.acting==='rest'&&!d.reduced,w=1-Math.exp(-dt*4); |
| 18 | gaze.x=R.lerp(gaze.x,active?target.x:0,w);gaze.y=R.lerp(gaze.y,active?target.y:0,w); |
| 19 | const p=d.pose();return {lookX:p.lookX+gaze.x*.85,lookY:p.lookY+gaze.y*.65,head:p.head-gaze.y*5,rot:p.rot+gaze.x*2}; |
| 20 | } |
| 21 | function palette(theme){return theme==='charcoal' |
| 22 | ? {air:'#243c4b',shore:'#426878',water:'#254e64',deep:'#1d4055',reed:'#4c8490',glint:'#699cab'} |
| 23 | : {air:'#edf5f5',shore:'#c4d8d7',water:'#d6eaed',deep:'#b9dce3',reed:'#86b5b6',glint:'#f7fcfb'};} |
| 24 | function fill(ctx,d,color,alpha=1){ctx.save();ctx.globalAlpha=alpha;ctx.fillStyle=color;ctx.beginPath();R.trace(ctx,R.parse(d));ctx.fill();ctx.restore();} |
| 25 | function behind(ctx,d,theme){ |
| 26 | const p=palette(theme),t=d.reduced?0:d.f/30,w=Math.sin(t*.55)*1.4; |
| 27 | fill(ctx,'M-59 7 C-63 -29 -37 -58 0 -59 C35 -62 60 -41 61 -6 C66 31 48 57 13 60 C-22 67 -54 48 -59 7 Z',p.air); |
| 28 | fill(ctx,'M-61 29 C-61 13 -56 5 -51 11 C-46 16 -48 22 -43 25 C-39 27 -37 30 -35 34 C-45 37 -55 36 -61 29 Z',p.shore); |
| 29 | fill(ctx,`M-60 ${21+w} C-37 ${15+w} -14 ${23-w} 6 21 C26 ${18+w} 43 ${15-w} 61 18 C61 42 42 59 12 61 C-19 65 -51 47 -60 ${21+w} Z`,p.water); |
| 30 | fill(ctx,'M-55 40 C-29 49 -9 42 12 45 C32 49 44 39 56 36 C44 56 21 63 -3 61 C-25 59 -44 49 -55 40 Z',p.deep,.7); |
| 31 | for(const [x,h,phase] of [[-54,29,0],[-49,22,1],[53,19,2],[57,26,3]]){ |
| 32 | const sway=Math.sin(t*.65+phase)*2; |
| 33 | fill(ctx,`M${x} 52 C${x-2} 42 ${x+sway-5} ${52-h+5} ${x+sway} ${52-h} C${x+sway-1} ${52-h+9} ${x+3} 40 ${x+1} 52 Z`,p.reed,.8); |
| 34 | } |
| 35 | fill(ctx,`M-53 33 C-45 ${30+w} -40 ${30+w} -34 33 C-42 32 -47 34 -53 33 Z`,p.glint,.7); |
| 36 | fill(ctx,`M35 30 C43 ${28-w} 48 ${28-w} 54 30 C47 30 42 32 35 30 Z`,p.glint,.65); |
| 37 | // Water bubbles live low in the cove, away from the thinking-cloud region. |
| 38 | for(let i=0;i<3;i++){ |
| 39 | const phase=d.reduced?.35:(t/(7+i*2)+i*.31)%1; |
| 40 | const x=[-47,47,39][i]+Math.sin(t*.6+i)*1.2,y=55-phase*24,r=.55+phase*.5; |
| 41 | ctx.save();ctx.globalAlpha=Math.sin(phase*Math.PI)*.65;ctx.fillStyle=p.glint;ctx.beginPath();R.trace(ctx,R.ellipse(x,y,r,r));ctx.fill();ctx.restore(); |
| 42 | } |
| 43 | } |
| 44 | function front(ctx,d,theme){ |
| 45 | const p=palette(theme),t=d.reduced?0:d.f/30,w=Math.sin(t*.45)*2; |
| 46 | // The near current passes below the face, preserving the white crescent. |
| 47 | fill(ctx,`M-40 51 C-23 ${47+w} -9 ${54-w} 8 51 C21 ${48+w} 30 49 40 47 C26 54 16 54 5 54 C-11 57 -26 50 -40 51 Z`,p.glint,.6); |
| 48 | fill(ctx,`M-23 58 C-8 ${56-w*.3} 8 60 21 57 C9 62 -8 60 -23 58 Z`,p.glint,.55); |
| 49 | for(const ripple of ripples){ |
| 50 | const age=d.reduced?12:d.f-ripple.at;if(age<0||age>78)continue; |
| 51 | for(let i=0;i<2;i++){ |
| 52 | const a=age-i*9;if(a<0)continue;const radius=2+a*.19,alpha=(1-a/78)*.72; |
| 53 | ctx.save();ctx.globalAlpha=alpha;ctx.fillStyle=p.glint;ctx.beginPath(); |
| 54 | R.trace(ctx,R.ellipse(ripple.x,ripple.y,radius,radius*.25)); |
| 55 | R.trace(ctx,R.ellipse(ripple.x,ripple.y,Math.max(0,radius-.7),Math.max(0,radius*.25-.55))); |
| 56 | ctx.fill('evenodd');ctx.restore(); |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | root.WhaleHabitat={behind,front,pose,observe,leave,tap,inspect:()=>({target:{...target},gaze:{...gaze},ripples:ripples.length})}; |
| 61 | })(window); |
| 62 |