1
<!doctype html>2
<html lang="zh-CN">3
<head>4
<meta charset="utf-8">5
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">6
<meta name="theme-color" content="#e4ecdc">7
<title>河岸慢递 · Riverside Express</title>8
<style>9
:root{color-scheme:light;--ink:#26483d;--muted:#648070;--paper:rgba(255,253,242,.86);--line:rgba(44,76,60,.14);--accent:#d96b53}10
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}body{font-family:"Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);background:#e4ecdc}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:4px}canvas#scene{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}canvas#scene:active{cursor:grabbing}.ui{position:fixed;inset:0;pointer-events:none}.ui::before{content:"";position:absolute;left:0;top:0;width:380px;height:270px;background:radial-gradient(ellipse at top left,rgba(244,245,222,.82),rgba(244,245,222,.4) 42%,transparent 72%);pointer-events:none}.ui button,.ui input,.ui details{pointer-events:auto}header{position:absolute;left:46px;top:36px}.eyebrow{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:3px;font-weight:650}.emblem{width:29px;height:29px;border:1px solid #789887;border-radius:50%;display:grid;place-items:center}.emblem svg{width:18px;height:18px}h1{font-family:"Songti SC","SimSun",serif;font-size:46px;font-weight:600;letter-spacing:6px;margin:16px 0 11px}.subtitle{font-size:12px;letter-spacing:2px;line-height:2;opacity:.75}.toolbar{position:absolute;top:34px;right:38px;display:flex;gap:9px}.iconbtn{display:grid;place-items:center;width:40px;height:40px;border-radius:50%;border:1px solid var(--line);background:var(--paper);color:var(--ink);backdrop-filter:blur(12px);transition:background .2s,transform .2s}.iconbtn:hover{background:#fffaf0;transform:translateY(-2px)}svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round}.toolbar .active{background:#dfeaca}.bottom{position:absolute;bottom:35px;left:50%;transform:translateX(-50%);display:flex;gap:14px;align-items:center;padding:10px 16px 10px 10px;border:1px solid rgba(255,255,255,.8);box-shadow:0 8px 32px #28452c12;border-radius:60px;background:var(--paper);backdrop-filter:blur(18px);white-space:nowrap}.play{border:0;border-radius:50%;width:40px;height:40px;background:var(--ink);color:#fff9e8;display:grid;place-items:center}.separator{height:22px;width:1px;background:var(--line)}.views{display:flex;gap:3px}.view{border:0;border-radius:20px;background:none;color:var(--muted);padding:8px 13px;font-size:12px;transition:background .2s}.view.active{background:#e3e9d6;color:var(--ink)}.pace{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--muted)}.pace span{min-width:30px;font-variant-numeric:tabular-nums}input[type=range]{accent-color:#688572;width:80px;height:3px;cursor:pointer}.note{position:absolute;bottom:42px;left:46px;font-size:10px;letter-spacing:1.5px;color:var(--muted);line-height:1.9}.route{display:flex;align-items:center;gap:9px;margin-bottom:6px;font-size:11px;color:var(--ink)}.route .dot{width:6px;height:6px;background:var(--accent);border-radius:50%;box-shadow:0 0 0 4px #d96b5316}.track{height:2px;background:#44694920;width:99px;margin-top:9px;position:relative}.track i{position:absolute;height:2px;width:0;background:#668b70;transition:width .3s linear}.hint{position:absolute;right:38px;bottom:47px;text-align:right;font-size:10px;line-height:1.9;letter-spacing:1px;color:var(--muted)}.hint b{font-weight:500;font-size:11px;color:var(--ink)}#help{position:absolute;right:38px;top:88px;width:270px;padding:22px;border-radius:20px;border:1px solid #ffffffb0;background:var(--paper);backdrop-filter:blur(18px);box-shadow:0 15px 45px #30513a16;font-size:12px;line-height:2;pointer-events:auto}#help[hidden]{display:none}#help strong{display:block;margin-bottom:12px;letter-spacing:2px}#help div{display:flex;justify-content:space-between;margin-top:3px;color:var(--muted)}kbd{font-family:inherit;font-size:10px;background:#dce4d533;border:1px solid var(--line);border-radius:5px;padding:0 6px;color:var(--ink)}#toast{position:absolute;bottom:112px;left:50%;transform:translate(-50%,10px);border:1px solid #fff9;background:var(--paper);backdrop-filter:blur(12px);padding:12px 21px;border-radius:30px;font-size:12px;letter-spacing:1px;opacity:0;transition:.35s;white-space:nowrap}#toast.show{opacity:1;transform:translate(-50%,0)}#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:18px;background:#e4ecdc;z-index:10;color:#41624d;font-size:12px;letter-spacing:3px;transition:opacity .7s}#loading .spinner{width:31px;height:31px;border:2px solid #93a18a44;border-top-color:#67846b;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.vignette{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 52% 48%,transparent 50%,rgba(255,246,220,.12) 100%)}body.dusk{--ink:#343852;--muted:#5d627d;--paper:rgba(249,240,236,.85);--line:rgba(65,62,87,.14)}11
@media(max-width:800px){header{left:24px;top:25px}h1{font-size:36px;margin-top:13px}.eyebrow{font-size:9px;letter-spacing:2px}.subtitle{font-size:10px}.toolbar{right:22px;top:25px;gap:6px}.iconbtn{width:34px;height:34px}.note{left:24px;bottom:110px}.hint{right:24px;bottom:112px}.bottom{bottom:28px;gap:10px;padding:8px 12px 8px 8px}.view{padding:8px 10px}.pace{gap:6px}input[type=range]{width:60px}#help{right:22px;top:70px}#toast{bottom:150px}}12
@media(max-width:480px){h1{font-size:30px;letter-spacing:4px}.eyebrow{letter-spacing:1.5px}.emblem{width:23px;height:23px}.toolbar{top:25px;right:15px;gap:5px}.toolbar .capture{display:none}.iconbtn{width:31px;height:31px}.bottom{gap:7px;width:calc(100% - 30px);justify-content:space-between;bottom:22px}.views{gap:1px}.view{padding:8px 9px;font-size:11px}.pace{gap:5px}input[type=range]{width:48px}.play{height:36px;width:36px}.bottom>.iconbtn{display:none}.note{bottom:95px}.hint{bottom:97px;font-size:9px}.subtitle{max-width:150px;font-size:9px;letter-spacing:1px}}13
</style>14
</head>15
<body>16
<canvas id="scene" aria-label="戴红围巾的水獭骑薄荷绿自行车,载着包裹沿河岸前进。拖动旋转视角,滚轮缩放。"></canvas>17
<div class="ui">18
<div class="vignette"></div>19
<header><div class="eyebrow"><span class="emblem"><svg viewBox="0 0 24 24"><path d="M3 13c3-4 5 4 9 0s6 3 9-1M3 18c3-4 5 4 9 0s6 3 9-1M8 8a4 4 0 0 1 8 0"/></svg></span>RIVERSIDE EXPRESS</div><h1>河岸慢递</h1><div class="subtitle">把日常的温柔,送到下一站。<br>一只水獭的沿河小旅程</div></header>20
<div class="toolbar">21
<button id="light" class="iconbtn" title="切换黄昏 · L" aria-label="切换黄昏" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M5 19l1.4-1.4M17.6 6.4 19 5"/></svg></button>22
<button id="sound" class="iconbtn" title="环境声 · M" aria-label="开启环境声" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="m11 4-6 5H2v6h3l6 5zM16 9l5 6m0-6-5 6"/></svg></button>23
<button id="capture" class="iconbtn capture" title="保存画面" aria-label="保存画面"><svg viewBox="0 0 24 24"><path d="M3 7h4l2-3h6l2 3h4v13H3z"/><circle cx="12" cy="13" r="4"/></svg></button>24
<button id="helpbtn" class="iconbtn" title="操作说明" aria-label="操作说明" aria-expanded="false"><svg viewBox="0 0 24 24"><path d="M9 8a3 3 0 1 1 5 2c-2 1-2 2-2 3"/><circle cx="12" cy="17" r=".6"/><circle cx="12" cy="12" r="10"/></svg></button>25
</div>26
<div id="help" hidden><strong>慢慢看,慢慢骑。</strong><div>旋转视角 <kbd>拖动 / 单指</kbd></div><div>拉近或远眺 <kbd>滚轮 / 双指</kbd></div><div>暂停 / 继续 <kbd>空格</kbd></div><div>按一下车铃 <kbd>B</kbd></div><div>回到跟拍视角 <kbd>R</kbd></div><div>光线 / 环境声 <kbd>L / M</kbd></div><div style="margin-top:12px">点一下水獭,它会和你打招呼。</div></div>27
<div class="note"><div class="route"><span class="dot"></span><span id="season">初秋 · 晴朗的早晨</span></div><span id="distance">旅程刚刚开始</span><div class="track"><i id="progress"></i></div></div>28
<div class="bottom"><button id="play" class="play" title="暂停 · 空格" aria-label="暂停" aria-pressed="false"><svg viewBox="0 0 24 24"><path d="M9 5v14M15 5v14" stroke-width="3"/></svg></button><span class="separator"></span><div class="views" role="group" aria-label="视角"><button class="view active" data-view="follow" aria-pressed="true">跟拍</button><button class="view" data-view="wide" aria-pressed="false">全景</button><button class="view" data-view="close" aria-pressed="false">近景</button></div><span class="separator"></span><label class="pace" title="骑行速度"><input id="speed" aria-label="骑行速度" type="range" min="0.5" max="1.8" value="1" step="0.1"><span id="speedtext">1.0×</span></label><button id="full" class="iconbtn" style="background:none;border:0;width:27px;height:27px" title="全屏" aria-label="全屏"><svg viewBox="0 0 24 24"><path d="M3 9V3h6m6 0h6v6M3 15v6h6m6 0h6v-6"/></svg></button></div>29
<div class="hint"><b>风从河面来</b><br>拖动环顾 · 滚轮靠近</div><div id="toast" role="status"></div>30
</div>31
<div id="loading"><div class="spinner"></div>正在准备这段小旅程…</div>32
<!-- THREE_INLINE -->33
<script>34
/* Riverside Express. All geometry, textures and audio are generated locally.35
Three.js r160 (MIT) is embedded above; no network, fonts or asset files required. */36
(()=>{'use strict';37
const T=THREE,PI=Math.PI,TAU=PI*2,$=s=>document.querySelector(s),V=(x=0,y=0,z=0)=>new T.Vector3(x,y,z);38
let renderer;try{renderer=new T.WebGLRenderer({canvas:$('#scene'),antialias:true,alpha:false,preserveDrawingBuffer:true,powerPreference:'high-performance'});}catch(e){$('#loading').innerHTML='<strong>暂时无法开启 3D 画面</strong><span style="max-width:320px;text-align:center;line-height:2;letter-spacing:0">请用支持 WebGL 的现代浏览器打开,并开启浏览器硬件加速。</span>';return;}39
renderer.setPixelRatio(Math.min(devicePixelRatio,1.65));renderer.setSize(innerWidth,innerHeight);renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.outputColorSpace=T.SRGBColorSpace;renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.12;40
const scene=new T.Scene();scene.background=new T.Color('#e1ebd9');scene.fog=new T.Fog('#e1ebd9',75,160);const camera=new T.PerspectiveCamera(42,innerWidth/innerHeight,.1,240);41
const ambient=new T.HemisphereLight('#f9f4dc','#8b9c72',2.0);scene.add(ambient);const sun=new T.DirectionalLight('#fff0ce',3.2);sun.position.set(-12,23,10);sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);Object.assign(sun.shadow.camera,{left:-15,right:15,top:15,bottom:-15,near:1,far:65});sun.shadow.bias=-.0004;sun.shadow.normalBias=.028;sun.shadow.radius=3;scene.add(sun,sun.target);const fill=new T.DirectionalLight('#d8eef3',.8);fill.position.set(20,8,-15);scene.add(fill);42
let seed=2749;function rand(a=0,b=1){seed=(seed*1664525+1013904223)>>>0;return a+(b-a)*seed/4294967296;}43
const mat=(c,r=.8,m=0)=>new T.MeshStandardMaterial({color:c,roughness:r,metalness:m});44
const M={fur:mat('#8f6547'),furDark:mat('#70503a'),cream:mat('#dcc098'),muzzle:mat('#ead2ae'),nose:mat('#332b29',.35),eye:mat('#211e1b',.12),glint:new T.MeshBasicMaterial({color:'#fff9dc'}),pink:mat('#bc8370'),frame:mat('#65b9aa',.38,.24),frameDark:mat('#397f76',.5,.12),rubber:mat('#343c38',.95),rim:mat('#dfd7ba',.38,.55),silver:mat('#b2b6a4',.35,.65),saddle:mat('#70473a',.85),red:mat('#da6451'),redDark:mat('#c14b43'),box:mat('#c89c61'),boxLight:mat('#e0bc83'),twine:mat('#806548'),paper:mat('#fff0ca'),wood:mat('#a08054'),woodDark:mat('#775b3e'),grass:mat('#a9ba7b'),earth:mat('#9ca771'),path:mat('#dfc89a'),stone:mat('#babda5'),water:mat('#66afa8'),leaf:[mat('#71965d'),mat('#86a66b'),mat('#9bb878'),mat('#648957'),mat('#b5bc75')],bark:mat('#827156'),stem:mat('#728752'),flower:[mat('#f3d17c'),mat('#edb897'),mat('#f3e3b4')],house:mat('#e9d5ab'),roof:mat('#ae7055'),window:mat('#4f7973',.3),duck:mat('#f1dfb8'),orange:mat('#db9d5e'),cloud:mat('#f9f4df')};45
const sphere=new T.SphereGeometry(1,28,20),lowSphere=new T.SphereGeometry(1,10,7),boxGeo=new T.BoxGeometry(1,1,1),unitCylinder=new T.CylinderGeometry(1,1,1,10);46
function mesh(g,m,p,x=0,y=0,z=0,sx=1,sy=1,sz=1){const o=new T.Mesh(g,m);o.position.set(x,y,z);o.scale.set(sx,sy,sz);o.castShadow=true;o.receiveShadow=true;p.add(o);return o;}47
function ball(p,m,x,y,z,sx,sy=sx,sz=sx,low=false){return mesh(low?lowSphere:sphere,m,p,x,y,z,sx,sy,sz);}48
function box(p,m,x,y,z,sx,sy,sz){return mesh(boxGeo,m,p,x,y,z,sx,sy,sz);}49
function group(p,x=0,y=0,z=0){const g=new T.Group();g.position.set(x,y,z);p.add(g);return g;}50
function rod(p,a,b,r,m,r2=r){const d=b.clone().sub(a),o=new T.Mesh(Math.abs(r-r2)<1e-6?unitCylinder:new T.CylinderGeometry(r2/r,1,1,12),m);o.position.copy(a).add(b).multiplyScalar(.5);o.quaternion.setFromUnitVectors(V(0,1,0),d.clone().normalize());o.scale.set(r,d.length(),r);o.castShadow=true;o.receiveShadow=true;p.add(o);return o;}51
function setRod(o,a,b,r){o.position.copy(a).add(b).multiplyScalar(.5);const d=b.clone().sub(a);o.quaternion.setFromUnitVectors(V(0,1,0),d.clone().normalize());o.scale.set(r,d.length(),r);}52
function tube(p,pts,r,m,segments=24){const curve=new T.CatmullRomCurve3(pts.map(v=>Array.isArray(v)?V(...v):v));return mesh(new T.TubeGeometry(curve,segments,r,6,false),m,p);}53
function torus(p,m,x,y,z,R,r,rotX=0,rotY=0){const o=mesh(new T.TorusGeometry(R,r,10,64),m,p,x,y,z);o.rotation.set(rotX,rotY,0);return o;}54
function canvasTexture(w,h,draw){const c=document.createElement('canvas');c.width=w;c.height=h;draw(c.getContext('2d'),w,h);const tx=new T.CanvasTexture(c);tx.colorSpace=T.SRGBColorSpace;return tx;}55
const furTex=canvasTexture(128,128,(c,w,h)=>{c.fillStyle='#aaa';c.fillRect(0,0,w,h);for(let i=0;i<5000;i++){const n=Math.floor(rand(120,180));c.fillStyle=`rgb(${n},${n},${n})`;c.fillRect(rand(0,w),rand(0,h),1,2);}});furTex.wrapS=furTex.wrapT=T.RepeatWrapping;furTex.repeat.set(3,3);M.fur.bumpMap=furTex;M.fur.bumpScale=.018;M.furDark.bumpMap=furTex;M.furDark.bumpScale=.014;56
const world=group(scene);57
mesh(new T.CylinderGeometry(88,88,1,128),M.earth,world,0,-.57,0);mesh(new T.CircleGeometry(88,128),M.grass,world,0,-.055,0).rotation.x=-PI/2;58
function riverAt(a){return V(24*Math.cos(a)+1.8*Math.sin(3*a),.02,16*Math.sin(a)+.7*Math.cos(2*a));}59
function outward(a){const d=riverAt(a+.001).sub(riverAt(a-.001)).normalize();return V(d.z,0,-d.x);}60
function riverWidth(a){return 3.65+.35*Math.sin(4*a+.6);}61
function routeAt(a){return riverAt(a).add(outward(a).multiplyScalar(6.8)).setY(.10);}62
function strip(fn,width,n=420,y){const pos=[],uv=[],idx=[];for(let i=0;i<=n;i++){let a=i/n*TAU,p=fn(a),normal=outward(a),w=typeof width==='function'?width(a):width;for(let k=0;k<2;k++){const q=p.clone().addScaledVector(normal,(k?1:-1)*w);pos.push(q.x,y??p.y,q.z);uv.push(i/n,k);}if(i<n){let j=i*2;idx.push(j,j+2,j+1,j+1,j+2,j+3);}}const g=new T.BufferGeometry();g.setAttribute('position',new T.Float32BufferAttribute(pos,3));g.setAttribute('uv',new T.Float32BufferAttribute(uv,2));g.setIndex(idx);g.computeVertexNormals();return g;}63
const shoreMat=mat('#c4c08d');mesh(strip(riverAt,a=>riverWidth(a)+.52,420,-.025),shoreMat,world);64
const waterUniforms={time:{value:0},day:{value:0}};65
const waterMat=new T.ShaderMaterial({uniforms:waterUniforms,side:T.DoubleSide,vertexShader:`varying vec3 wp; varying vec2 vUv; uniform float time; void main(){vUv=uv;vec3 p=position;p.y+=.015*sin(p.x*.7+time*1.3)*sin(p.z*.9-time*.8);wp=p;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);}`,fragmentShader:`varying vec3 wp;varying vec2 vUv;uniform float time;uniform float day;void main(){float w=sin(wp.x*2.2+wp.z*3.5-time*1.2)+sin(wp.z*5.8-wp.x*.8+time*.7);float fine=sin(wp.x*15.+wp.z*21.-time*2.);float glint=pow(max(0.,sin(wp.x*4.+wp.z*9.+time*.8)*sin(wp.z*6.-time*.45)),20.)*.25;float edge=pow(abs(vUv.y*2.-1.),6.);vec3 c=mix(vec3(.12,.38,.35),vec3(.30,.54,.42),edge);c+=w*.018+glint+fine*.004;c=mix(c,c*vec3(.81,.76,1.06)+vec3(.1,.015,.025),day);gl_FragColor=vec4(c,1.);\n#include <tonemapping_fragment>\n#include <colorspace_fragment>}`});66
const river=mesh(strip(riverAt,riverWidth),waterMat,scene);river.castShadow=false;river.receiveShadow=false;67
mesh(strip(routeAt,1.85,420,.063),mat('#c5b782'),world);mesh(strip(routeAt,1.61,420,.085),M.path,world);68
// Thin, irregular wavelets and gravel give the banks scale without noisy textures.69
const rippleMats=[mat('#9ac9b3'),mat('#c4dec3')];for(let i=0;i<170;i++){const a=rand(0,TAU),p=riverAt(a).addScaledVector(outward(a),rand(-2.9,2.9));const o=ball(world,rippleMats[i%3?0:1],p.x,.047,p.z,rand(.12,.42),.009,rand(.018,.045),true);o.rotation.y=rand(-.4,.4);o.castShadow=false;}70
for(let i=0;i<260;i++){const a=rand(0,TAU),p=routeAt(a).addScaledVector(outward(a),rand(-1.4,1.4));ball(world,i%3?M.boxLight:M.stone,p.x,.094,p.z,rand(.015,.044),.005,rand(.015,.055),true).castShadow=false;}71
const swaying=[];72
function tree(p,s=1,willow=false){const g=group(world,p.x,0,p.z);g.rotation.y=rand(0,TAU);rod(g,V(0,0,0),V(.12,2.9*s,0),.14*s,M.bark,.07*s);for(let j=0;j<4;j++){const a=j*TAU/4,root=V(.06,1.8*s,0),tip=V(Math.cos(a)*.85*s,3.2*s+rand(-.2,.3),Math.sin(a)*.85*s);rod(g,root,tip,.06*s,M.bark,.025*s);ball(g,M.leaf[(j+Math.floor(s*3))%5],tip.x,tip.y+.3*s,tip.z,1.05*s,willow?.84*s:1.25*s,.88*s,true);if(willow){for(let k=0;k<5;k++){const xx=tip.x+rand(-.65,.65)*s,zz=tip.z+rand(-.65,.65)*s;tube(g,[[xx,tip.y+.3*s,zz],[xx+.13*s,tip.y-.3*s,zz+.16*s],[xx+.06*s,tip.y-1.6*s,zz+.17*s]],.045*s,M.leaf[1],9);}}}return g;}73
for(let i=0;i<51;i++){const a=i/51*TAU+rand(-.028,.028),p=routeAt(a).addScaledVector(outward(a),rand(18,22));tree(p,rand(.65,1.18),i%7===0);}74
for(let i=0;i<19;i++){const a=i/19*TAU,p=riverAt(a).addScaledVector(outward(a),-riverWidth(a)-rand(2,6));tree(p,rand(.65,1.15),i%6===0);}75
// Willow on the first bend.76
tree(routeAt(-1.9).addScaledVector(outward(-1.9),18),1.25,true);77
for(let i=0;i<27;i++){const a=rand(0,TAU),p=routeAt(a).addScaledVector(outward(a),rand(3,7));ball(world,M.leaf[i%5],p.x,.35,p.z,rand(.4,1),rand(.3,.6),rand(.4,.8),true);}78
for(let i=0;i<75;i++){const a=rand(0,TAU),n=outward(a),p=riverAt(a).addScaledVector(n,riverWidth(a)+rand(.25,1.05));const g=group(world,p.x,0,p.z);for(let k=0;k<4;k++){const x=rand(-.22,.22),z=rand(-.22,.22),h=rand(.35,.8);rod(g,V(x,.03,z),V(x+.08,h,z-.04),.014,M.stem);if(k%2)ball(g,M.woodDark,x+.08,h,z-.04,.035,.14,.035,true);else{const leaf=mesh(new T.ConeGeometry(.06,h*.85,4),M.leaf[i%5],g,x+.03,h*.48,z);leaf.rotation.z=-.16;}}}79
for(let i=0;i<160;i++){const a=rand(0,TAU),p=routeAt(a).addScaledVector(outward(a),rand(2.1,4.2));const h=rand(.12,.4);rod(world,V(p.x,0,p.z),V(p.x,h,p.z),.009,M.stem);const f=group(world,p.x,h,p.z);for(let k=0;k<5;k++){const b=k/5*TAU;ball(f,M.flower[i%3],Math.cos(b)*.055,0,Math.sin(b)*.055,.042,.017,.035,true);}ball(f,M.orange,0,.014,0,.027,.02,.027,true);}80
for(let i=0;i<25;i++){const a=rand(0,TAU),p=riverAt(a).addScaledVector(outward(a),rand(-2.2,2.2));const pad=mesh(new T.CircleGeometry(rand(.16,.35),18,0,TAU-.45),M.leaf[3],world,p.x,.05,p.z);pad.rotation.x=-PI/2;pad.rotation.z=rand(0,TAU);pad.castShadow=false;if(i%5===0){for(let k=0;k<6;k++){const b=k/6*TAU;const o=ball(world,M.flower[1],p.x+Math.cos(b)*.045,.085,p.z+Math.sin(b)*.045,.048,.085,.023,true);o.rotation.z=Math.sin(b)*.5;}ball(world,M.flower[0],p.x,.085,p.z,.04);}}81
for(let i=0;i<65;i++){const a=rand(0,TAU),p=riverAt(a).addScaledVector(outward(a),riverWidth(a)+rand(.3,1.5));const s=rand(.09,.36);ball(world,M.stone,p.x,.08,p.z,s,s*.6,s*.8,true).rotation.y=rand(0,TAU);}82
function bench(p,angle){const g=group(world,p.x,0,p.z);g.rotation.y=angle;for(let j=0;j<4;j++){box(g,M.wood,0,.48,-.24+j*.15,1.65,.075,.12);box(g,M.wood,0,.76+j*.13,-.35,1.65,.1,.065);}for(let x of [-.62,.62]){rod(g,V(x,.08,.24),V(x,.48,.24),.035,M.frameDark);rod(g,V(x,.08,-.29),V(x,1.12,-.29),.035,M.frameDark);}}83
for(let a of [-2.72,-.43,1.78,2.66])bench(routeAt(a).addScaledVector(outward(a),2.9),-a+.15);84
const lampGlows=[];for(let a of [-2.3,-1.0,.4,1.9,2.9]){const p=routeAt(a).addScaledVector(outward(a),2.45),g=group(world,p.x,0,p.z);rod(g,V(0,0,0),V(0,2.7,0),.045,M.frameDark);box(g,M.frameDark,0,2.58,0,.24,.055,.24);box(g,M.frameDark,0,2.99,0,.27,.055,.27);const lightMat=mat('#f4e8ba');lightMat.emissive=new T.Color('#edb869');lightMat.emissiveIntensity=.1;box(g,lightMat,0,2.79,0,.16,.35,.16);lampGlows.push(lightMat);mesh(new T.ConeGeometry(.24,.18,4),M.frameDark,g,0,3.1,0).rotation.y=PI/4;}85
function signTexture(txt,sub){return canvasTexture(512,256,(c,w,h)=>{c.fillStyle='#38695e';c.fillRect(0,0,w,h);c.strokeStyle='#d4dfb7';c.lineWidth=3;c.strokeRect(16,16,w-32,h-32);c.textAlign='center';c.fillStyle='#fff2cc';c.font='bold 68px "Microsoft YaHei",sans-serif';c.fillText(txt,w/2,116);c.font='23px sans-serif';c.fillText(sub,w/2,179);});}86
function building(p,angle){const g=group(world,p.x,0,p.z);g.rotation.y=angle;box(g,M.house,0,1.55,0,4.2,3.1,3.2);const roofGeo=new T.BufferGeometry();roofGeo.setAttribute('position',new T.Float32BufferAttribute([-2.4,3.05,-1.85,2.4,3.05,-1.85,0,4.25,-1.85,-2.4,3.05,1.85,0,4.25,1.85,2.4,3.05,1.85,-2.4,3.05,-1.85,0,4.25,-1.85,0,4.25,1.85,-2.4,3.05,-1.85,0,4.25,1.85,-2.4,3.05,1.85,2.4,3.05,1.85,0,4.25,1.85,0,4.25,-1.85,2.4,3.05,1.85,0,4.25,-1.85,2.4,3.05,-1.85],3));roofGeo.computeVertexNormals();mesh(roofGeo,M.roof,g);box(g,M.woodDark,0,.95,1.63,.8,1.9,.08);ball(g,M.orange,.26,.9,1.7,.055);for(let x of [-1.35,1.35]){box(g,M.paper,x,1.55,1.64,.91,1.4,.1);box(g,M.window,x,1.55,1.7,.71,1.2,.03);box(g,M.paper,x,1.55,1.73,.065,1.2,.05);box(g,M.paper,x,1.55,1.73,.73,.065,.05);box(g,M.woodDark,x,.7,1.77,1.03,.12,.35);for(let j=0;j<5;j++)ball(g,M.leaf[j%4],x-.35+j*.17,.89,1.83,.15,.22,.15,true);}87
const sm=new T.MeshStandardMaterial({map:signTexture('河岸邮局','RIVERSIDE POST · EST. 1928'),roughness:.85});box(g,sm,0,2.61,1.67,2.35,.84,.05);for(let k=0;k<12;k++){const o=box(g,k%2?M.paper:M.red, -1.94+k*.352,2.22,2.06,.36,.075,.97);o.rotation.x=.13;}for(let x of [-2,2])rod(g,V(x,0,2.44),V(x,2.2,2.44),.05,M.wood);box(g,M.stone,0,.08,2.1,4.7,.16,1.25);const mb=group(g,2.8,0,1.8);rod(mb,V(0,0,0),V(0,1.13,0),.08,M.wood);box(mb,M.frameDark,0,1.4,0,.65,.58,.53);box(mb,M.nose,0,1.47,.271,.4,.06,.015);box(mb,M.paper,0,1.24,.272,.18,.12,.02);}88
const houseA=-2.07;building(routeAt(houseA).addScaledVector(outward(houseA),20),Math.atan2(-outward(houseA).x,-outward(houseA).z));89
// A little timber bridge connects both banks; its arched planks follow the span.90
const bridgeAngle=.58,bp=riverAt(bridgeAngle),br=group(world,bp.x,0,bp.z),bn=outward(bridgeAngle);br.rotation.y=Math.atan2(bn.x,bn.z);91
for(let i=0;i<28;i++){const z=-4.8+i*9.6/27,y=.13+.72*Math.sin(PI*i/27);const plank=box(br,M.wood,0,y,z,2.55,.13,.31);plank.rotation.x=-.72*PI/9.6*Math.cos(PI*i/27);if(i%3===0){for(let x of [-1.14,1.14])rod(br,V(x,y,z),V(x,y+.83,z),.055,M.woodDark);}}92
for(let x of [-1.14,1.14]){const pts=[];for(let i=0;i<29;i++)pts.push(V(x,1+.72*Math.sin(PI*i/28),-4.8+i*9.6/28));tube(br,pts,.06,M.woodDark,56);tube(br,pts.map(p=>p.clone().add(V(0,-.35,0))),.035,M.wood,56);}93
// Mooring platform, small boat, and a tied rope.94
const dockA=-.1,dp=riverAt(dockA).addScaledVector(outward(dockA),3.8),dock=group(world,dp.x,0,dp.z);dock.rotation.y=-dockA;95
for(let i=0;i<12;i++)box(dock,M.wood,0,.17,-i*.19,2,.1,.16);for(let x of [-.8,.8])for(let z of [0,-2])rod(dock,V(x,-.3,z),V(x,.7,z),.065,M.woodDark);96
const boat=group(world,dp.x-2.5,.05,dp.z-1.4);boat.rotation.y=.25;ball(boat,M.woodDark,0,.06,0,1.25,.22,.44,true);ball(boat,M.wood,0,.20,0,1.12,.17,.38,true);ball(boat,M.water,0,.27,0,.85,.025,.27,true);for(let x of [-.5,.1,.6])box(boat,M.wood,x,.32,0,.13,.08,.67);rod(boat,V(-.6,.42,-.48),V(.8,.42,.5),.026,M.woodDark);tube(world,[[dp.x-.8,.7,dp.z-2],[dp.x-1.5,.4,dp.z-2.1],[dp.x-2.5,.33,dp.z-1.4]],.017,M.twine,12);97
// Distant rolling hills remain gentle and low behind the river.98
for(let i=0;i<18;i++){const a=i/18*TAU,r=rand(61,83),p=V(Math.cos(a)*r,0,Math.sin(a)*r);ball(world,M.leaf[(i+1)%5],p.x,-1.5,p.z,rand(8,17),rand(4,8),rand(7,16),true);}99
const clouds=group(scene);for(let i=0;i<15;i++){const a=i/15*TAU,g=group(clouds,Math.cos(a)*rand(55,90),rand(18,26),Math.sin(a)*rand(55,90));for(let j=0;j<5;j++)ball(g,M.cloud,(j-2)*2.5,rand(-.3,.6),rand(-.4,.4),rand(2,3.6),rand(.8,1.6),rand(1.5,2.5),true).castShadow=false;}100
// Merge static scenery by material to keep draw calls low on ordinary laptops.101
function batchStatic(root){root.updateMatrixWorld(true);const buckets=new Map();root.traverse(o=>{if(!o.isMesh)return;const key=o.material.uuid+'_'+o.castShadow+'_'+o.receiveShadow;let b=buckets.get(key);if(!b){b={mat:o.material,cast:o.castShadow,receive:o.receiveShadow,gs:[]};buckets.set(key,b);}const transform=new T.Matrix4().copy(root.matrixWorld).invert().multiply(o.matrixWorld);const g=(o.geometry.index?o.geometry.toNonIndexed():o.geometry.clone()).applyMatrix4(transform);b.gs.push(g);});root.clear();for(const b of buckets.values()){const total=b.gs.reduce((s,g)=>s+g.attributes.position.count,0),p=new Float32Array(total*3),n=new Float32Array(total*3),uv=new Float32Array(total*2);let j=0;for(const g of b.gs){p.set(g.attributes.position.array,j*3);if(g.attributes.normal)n.set(g.attributes.normal.array,j*3);if(g.attributes.uv)uv.set(g.attributes.uv.array,j*2);j+=g.attributes.position.count;g.dispose();}const g=new T.BufferGeometry();g.setAttribute('position',new T.BufferAttribute(p,3));g.setAttribute('normal',new T.BufferAttribute(n,3));g.setAttribute('uv',new T.BufferAttribute(uv,2));const o=mesh(g,b.mat,root);o.castShadow=b.cast;o.receiveShadow=b.receive;}}102
batchStatic(world);103
// Bicycle is built in a +X travel coordinate frame. Both wheels turn about Z.104
const rider=group(scene),bike=group(rider),rear=V(-1.04,.77,0),front=V(1.05,.77,0),crankCenter=V(-.04,.88,0),seat=V(-.39,1.65,0),neck=V(.69,1.67,0);105
const wheels=[];for(const c of [rear,front]){const g=group(bike,c.x,c.y,c.z);torus(g,M.rubber,0,0,0,.61,.061);torus(g,M.rim,0,0,0,.565,.019);torus(g,M.frameDark,0,0,0,.529,.012);rod(g,V(0,0,-.095),V(0,0,.095),.07,M.silver);for(let i=0;i<24;i++){const a=i/24*TAU;rod(g,V(0,0,i%2?.05:-.05),V(Math.cos(a)*.56,Math.sin(a)*.56,0),.0075,M.silver);}box(g,M.orange,.37,.36,.005,.065,.11,.025);wheels.push(g);}106
for(const [a,b] of [[rear,seat],[seat,crankCenter],[crankCenter,rear],[seat,neck],[crankCenter,neck]])rod(bike,a,b,.045,M.frame);107
rod(bike,V(-.36,1.57,0),V(-.45,1.81,0),.028,M.silver);ball(bike,M.saddle,-.44,1.82,0,.27,.073,.17);rod(bike,front,neck,.047,M.frame);for(let z of [-.105,.105])rod(bike,V(1.05,.77,z),V(.77,1.58,z*.5),.027,M.frame);108
rod(bike,neck,V(.72,1.98,0),.026,M.silver);tube(bike,[[.72,1.98,0],[.72,2.035,-.27],[.84,2.04,-.36]],.024,M.silver);tube(bike,[[.72,1.98,0],[.72,2.035,.27],[.84,2.04,.36]],.024,M.silver);for(let z of [-.36,.36])rod(bike,V(.79,2.04,z),V(.94,2.04,z),.036,M.saddle);109
ball(bike,M.silver,.78,2.10,.15,.067,.045,.067);ball(bike,M.orange,1.06,1.43,0,.095,.095,.08);torus(bike,M.silver,1.105,1.43,0,.074,.012,0,PI/2);tube(bike,[[.8,2.02,.1],[1.0,1.84,.11],[.6,1.43,.13]],.009,M.nose,15);110
function mudguard(center){const pts=[];for(let i=0;i<=28;i++){const a=.12+(PI-.24)*i/28;pts.push(V(center.x+.699*Math.cos(a),center.y+.699*Math.sin(a),0));}tube(bike,pts,.035,M.frame,40);for(let s of [-1,1])rod(bike,V(center.x,center.y,s*.07),V(center.x+s*.56,center.y+.4,s*.05),.011,M.silver);}111
mudguard(rear);mudguard(front);wheels.forEach(w=>batchStatic(w));112
const crank=group(bike,crankCenter.x,crankCenter.y,0);torus(bike,M.silver,crankCenter.x,crankCenter.y,.10,.16,.019);torus(bike,M.silver,rear.x,rear.y,.095,.08,.012);113
// A continuous chain loop lies beside the frame.114
tube(bike,[[-1.04,.86,.12],[-.05,1.045,.12],[.125,.91,.12],[-.03,.72,.12],[-1.06,.69,.12],[-1.12,.77,.12],[-1.04,.86,.12]],.012,M.nose,38);115
const pedals=[];for(let s of [-1,1]){rod(crank,V(0,0,s*.12),V(s*.255,0,s*.24),.02,M.silver);const p=group(bike);box(p,M.rubber,0,0,0,.22,.055,.14);pedals.push(p);}116
// Rear rack and parcels: rounded cardboard, packing tape, string and an address label.117
for(let z of [-.19,.19]){rod(bike,V(-1.04,.78,z),V(-1.34,1.58,z),.019,M.silver);rod(bike,V(-.69,1.39,z),V(-1.38,1.58,z),.016,M.silver);}box(bike,M.wood,-1.18,1.57,0,.82,.065,.57);118
const cargo=group(bike,-1.22,1.62,0);box(cargo,M.box,0,.275,0,.75,.53,.62);box(cargo,M.boxLight,0,.545,0,.76,.035,.63);box(cargo,M.boxLight,0,.28,.314,.13,.5,.012);box(cargo,M.twine,0,.551,0,.016,.01,.63);box(cargo,M.twine,-.15,.551,0,.73,.01,.017);box(cargo,M.twine,.382,.28,0,.012,.54,.017);const pkg2=group(cargo,-.1,.565,-.07);pkg2.rotation.y=.16;box(pkg2,M.boxLight,0,.19,0,.52,.38,.48);box(pkg2,M.twine,0,.389,0,.012,.01,.49);box(pkg2,M.twine,0,.20,.246,.012,.39,.01);box(pkg2,M.twine,0,.389,0,.52,.01,.012);tube(pkg2,[[-.01,.39,0],[-.10,.45,.06],[-.13,.46,0],[-.01,.4,0],[.10,.46,-.05],[.14,.44,0],[0,.4,0]],.011,M.twine,20);119
const labelTex=canvasTexture(256,128,(c,w,h)=>{c.fillStyle='#fff0cc';c.fillRect(0,0,w,h);c.fillStyle='#526e59';c.font='bold 28px sans-serif';c.fillText('WITH CARE',20,44);c.fillStyle='#ab9980';for(let i=0;i<3;i++)c.fillRect(22,65+i*14,128-i*18,3);c.strokeStyle='#bf6c54';c.lineWidth=3;c.strokeRect(188,22,45,50);c.font='24px serif';c.fillStyle='#bf6c54';c.fillText('♡',194,57);});const label=new T.MeshStandardMaterial({map:labelTex,roughness:.9});mesh(new T.PlaneGeometry(.45,.225),label,cargo,.005,.29,.322); 120
// Wicker front basket, a letter and a sprig of yellow flowers.121
const basket=group(bike,1.29,1.58,0);for(let z of [-.23,.23])rod(bike,V(1.05,.81,z),V(1.3,1.56,z),.013,M.silver);box(basket,M.woodDark,0,0,0,.51,.04,.57);for(let j=0;j<6;j++){let y=.035+j*.057,w=.49+y*.27,d=.55+y*.24;for(let z of [-d/2,d/2])rod(basket,V(-w/2,y,z),V(w/2,y,z),.018,M.boxLight);for(let x of [-w/2,w/2])rod(basket,V(x,y,-d/2),V(x,y,d/2),.018,M.boxLight);}for(let k=0;k<6;k++){for(let z of [-.28,.28])rod(basket,V(-.23+k*.093,0,z),V(-.28+k*.112,.35,z*1.14),.012,M.wood);for(let x of [-.25,.25])rod(basket,V(x,0,-.24+k*.096),V(x*1.14,.35,-.29+k*.116),.012,M.wood);}122
const letter=box(basket,M.paper,0,.24,.03,.055,.4,.38);letter.rotation.z=-.2;for(let i=0;i<3;i++){let x=.1+rand(-.07,.07),z=-.13+rand(-.09,.09);rod(basket,V(x,.2,z),V(x-.07,.57+i*.065,z),.01,M.stem);for(let k=0;k<6;k++){const a=k/6*TAU;ball(basket,M.flower[0],x-.07,.57+i*.065+Math.sin(a)*.052,z+Math.cos(a)*.052,.023,.036,.035);}ball(basket,M.woodDark,x-.069,.57+i*.065,z,.024);}123
// Otter: tapered tail, rounded body, cream muzzle, tiny ears, visible whiskers.124
const otter=group(rider),body=group(otter);const torso=ball(body,M.fur,-.35,2.23,0,.40,.58,.34);torso.rotation.z=-.21;const bib=ball(body,M.cream,-.015,2.25,0,.16,.40,.255);bib.rotation.z=-.18;ball(body,M.fur,-.49,1.87,0,.39,.29,.34);125
const tailCurve=new T.CatmullRomCurve3([V(-.61,1.83,-.08),V(-.95,1.36,.06),V(-1.53,1.11,.19),V(-1.93,1.17,.23),V(-2.12,1.26,.23)]);const tailGeo=new T.TubeGeometry(tailCurve,38,.18,14,false),tp=tailGeo.attributes.position;for(let i=0;i<=38;i++){const c=tailCurve.getPointAt(i/38),r=(1-i/38)*.95+.06;for(let j=0;j<=14;j++){let idx=i*15+j;const p=V(tp.getX(idx),tp.getY(idx),tp.getZ(idx));p.sub(c).multiplyScalar(r).add(c);tp.setXYZ(idx,p.x,p.y,p.z);}}tailGeo.computeVertexNormals();mesh(tailGeo,M.fur,otter);126
const head=group(body,-.16,2.96,0);ball(head,M.fur,0,0,0,.50,.45,.415);ball(head,M.cream,.265,-.15,0,.26,.255,.325);for(let s of [-1,1]){ball(head,M.fur,-.19,.30,s*.345,.115,.125,.09);ball(head,M.pink,-.145,.32,s*.402,.055,.065,.023);ball(head,M.muzzle,.365,-.12,s*.115,.17,.133,.17);ball(head,M.pink,.276,-.08,s*.267,.042,.056,.016);}127
ball(head,M.nose,.513,-.085,0,.085,.064,.095);ball(head,M.glint,.571,-.058,.019,.012);128
const eyes=[];for(let s of [-1,1]){const e=group(head,.22,.10,s*.335);ball(e,M.eye,0,0,0,.066,.085,.040);ball(e,M.glint,.031,.032,s*.018,.018);eyes.push(e);tube(head,[[.14,.24,s*.345],[.23,.26,s*.33],[.30,.235,s*.30]],.018,M.furDark,9);for(let k=0;k<3;k++){tube(head,[[.37,-.13+k*.053,s*.22],[.40,-.17+k*.085,s*.43],[.38,-.21+k*.12,s*.61]],.0075,M.cream,10);}}129
tube(head,[[.514,-.153,-.08],[.52,-.20,0],[.514,-.153,.08]],.011,M.furDark,12);rod(head,V(.524,-.13,0),V(.523,-.185,0),.008,M.furDark);130
// Neck scarf, woven striping, and two physically suggestive travelling waves.131
const scarfTex=canvasTexture(256,128,(c,w,h)=>{c.fillStyle='#d96855';c.fillRect(0,0,w,h);c.fillStyle='#ed9574';for(let x=12;x<w;x+=43)c.fillRect(x,0,4,h);c.fillStyle='#b94d45';c.fillRect(0,8,w,5);c.fillRect(0,h-13,w,5);for(let i=0;i<2000;i++){c.fillStyle=i%2?'#ffffff0d':'#4c27270d';c.fillRect(rand(0,w),rand(0,h),1,1);}});const scarfMat=new T.MeshStandardMaterial({map:scarfTex,roughness:1,side:T.DoubleSide});const collar=torus(body,M.red,-.23,2.65,0,.335,.075,PI/2);collar.scale.set(1.1,1,.93);ball(body,M.redDark,-.58,2.66,.12,.13,.12,.14);132
const ribbons=[];for(let k=0;k<2;k++){const n=36,g=new T.BufferGeometry(),p=new Float32Array((n+1)*2*3),uv=[],idx=[];for(let i=0;i<=n;i++){uv.push(i/n,0,i/n,1);if(i<n){const j=i*2;idx.push(j,j+2,j+1,j+1,j+2,j+3);}}g.setAttribute('position',new T.BufferAttribute(p,3));g.setAttribute('uv',new T.Float32BufferAttribute(uv,2));g.setIndex(idx);const o=mesh(g,scarfMat,body);o.frustumCulled=false;ribbons.push({g,n,k,fringe:[]});for(let j=0;j<6;j++){const r=rod(body,V(),V(.02,0,0),.009,M.red);ribbons[k].fringe.push(r);}}133
const arms=[];for(let s of [-1,1]){const up=rod(otter,V(),V(0,.5,0),.092,M.fur),lo=rod(otter,V(),V(0,.5,0),.075,M.fur),paw=ball(otter,M.fur,.8,2.07,s*.34,.11,.085,.10);arms.push({s,up,lo,paw});}134
const legs=[];for(let s of [-1,1]){const thigh=rod(otter,V(),V(0,.5,0),.123,M.fur),shin=rod(otter,V(),V(0,.5,0),.082,M.fur),knee=ball(otter,M.fur,0,0,0,.123),foot=ball(otter,M.fur,0,0,0,.185,.085,.108);legs.push({s,thigh,shin,knee,foot});for(let k=0;k<3;k++){const nail=ball(foot,M.cream,.73,-.15,(k-1)*.32,.12,.1,.1);nail.castShadow=false;}}135
// A soft contact patch anchors the bicycle between its two real shadowed tyres.136
const contactTex=canvasTexture(128,128,(c,w,h)=>{const g=c.createRadialGradient(w/2,h/2,2,w/2,h/2,w*.48);g.addColorStop(0,'rgba(48,55,32,.24)');g.addColorStop(.5,'rgba(48,55,32,.12)');g.addColorStop(1,'rgba(48,55,32,0)');c.fillStyle=g;c.fillRect(0,0,w,h);});const contact=mesh(new T.PlaneGeometry(3.5,1.5),new T.MeshBasicMaterial({map:contactTex,transparent:true,depthWrite:false}),rider,0,.099,0);contact.rotation.x=-PI/2;contact.castShadow=false;137
// Ducks bob independently and leave widening wakes.138
const ducks=[];for(let i=0;i<7;i++){const a=i/7*TAU+.15,p=riverAt(a).addScaledVector(outward(a),rand(-2.3,1.5)),g=group(scene,p.x,.07,p.z);g.rotation.y=rand(0,TAU);ball(g,M.duck,0,.17,0,.32,.22,.22);ball(g,M.duck,.19,.41,0,.135,.17,.13);box(g,M.orange,.35,.39,0,.16,.04,.09);for(let s of [-1,1])ball(g,M.eye,.255,.45,s*.092,.014);ball(g,M.boxLight,-.05,.24,.17,.22,.12,.06);const wakes=[];for(let j=0;j<3;j++){const w=torus(g,new T.MeshBasicMaterial({color:'#b3d5bc',transparent:true,opacity:.22}),-.43-j*.13,-.025,0,.3+j*.14,.006,PI/2);w.scale.x=1.25;w.castShadow=false;wakes.push(w);}ducks.push({g,p,a,i});}139
// Small birds: two gently flapping wings each.140
const birds=[];for(let i=0;i<6;i++){const g=group(scene),wings=[];for(let s of [-1,1]){const pivot=group(g);const wing=ball(pivot,M.paper,0,0,s*.15,.09,.023,.22,true);wings.push({pivot,s});}ball(g,M.woodDark,0,0,0,.07,.04,.15,true);birds.push({g,wings,i});}141
let paused=false,speed=1,targetSpeed=1,sim=0,clothTime=0,distance=0,phase=0,view='follow',dusk=false,duskMix=0,waveUntil=-10,toastTimer,orbitYaw=.47,orbitElev=.29,orbitDist=8.4;142
let userYaw=orbitYaw,userElev=orbitElev,userDist=orbitDist,initializedCamera=false,camLook=V(),camGoal=V(),last=performance.now(),frameMs=16,qualitySamples=0;143
// Arc-length lookup: wheel rotation and leg cadence depend on actual distance travelled.144
const routeTable=[0];let plen=0,prev=routeAt(0);for(let i=1;i<=1500;i++){const p=routeAt(i/1500*TAU);plen+=p.distanceTo(prev);routeTable.push(plen);prev=p;}const routeLength=plen;145
function angleAtDistance(d){const s=((d%routeLength)+routeLength)%routeLength;let lo=0,hi=1500;while(hi-lo>1){let mid=(lo+hi)>>1;if(routeTable[mid]<s)lo=mid;else hi=mid;}return (lo+(s-routeTable[lo])/(routeTable[hi]-routeTable[lo]))/1500*TAU;}146
// Starting bend is selected for a clear view of the river and post office.147
let initialDistance=routeTable[Math.round(1500*(TAU-PI/2-.18)/TAU)];148
function ribbonAt(u,k,t){const len=k?1.02:1.54,wave=Math.sin(u*7.8-t*4.2+k)*u;return V(-.58-u*len,2.67+(k?-.04:.05)+u*(.16+.035*speed)+wave*.12,.12+(k?-.14:.03)+Math.sin(u*6.5-t*3.6+k)*u*.12);}149
function updateOtter(dt){const bob=Math.sin(phase*2)*.022,breath=Math.sin(sim*2.1)*.008;body.position.y=bob+breath;body.rotation.z=Math.sin(phase)*.009;head.rotation.z=Math.sin(sim*1.6)*.025;head.rotation.y=Math.sin(sim*.55)*.055;150
const blinkTime=sim%5.8,blink=blinkTime>5.55?1-Math.sin((blinkTime-5.55)/.25*PI)*.96:1;eyes.forEach(e=>e.scale.y=blink);151
for(const r of ribbons){const p=r.g.attributes.position;for(let i=0;i<=r.n;i++){const u=i/r.n,c=ribbonAt(u,r.k,clothTime),width=(r.k?.145:.22)*(1-u*.12);for(let s=0;s<2;s++){const idx=i*2+s;p.setXYZ(idx,c.x,c.y+(s?1:-1)*width/2,c.z+(s?1:-1)*Math.sin(u*9-sim*4)*.025*u);}}p.needsUpdate=true;r.g.computeVertexNormals();const end=ribbonAt(1,r.k,clothTime);r.fringe.forEach((f,j)=>{const p=end.clone();p.y+=(j/5-.5)*(r.k?.145:.20);setRod(f,p,p.clone().add(V(-.10,.012*Math.sin(sim*5+j),.02*Math.sin(sim*4+j))),.008);});}152
for(let i=0;i<legs.length;i++){const l=legs[i],a=phase+(l.s>0?0:PI),p=V(crankCenter.x+Math.cos(a)*.255,crankCenter.y+Math.sin(a)*.255,l.s*.27);pedals[i].position.copy(p);const hip=V(-.38,1.84+bob,l.s*.24),ankle=p.clone().add(V(-.03,.088,l.s*.035));const d=ankle.clone().sub(hip),L=d.length(),l1=.57,l2=.59,h=(l1*l1-l2*l2+L*L)/(2*L),height=Math.sqrt(Math.max(.01,l1*l1-h*h)),axis=d.clone().normalize(),perp=V(-axis.y,axis.x,0);const knee=hip.clone().addScaledVector(axis,h).addScaledVector(perp,height);knee.z=l.s*.34;setRod(l.thigh,hip,knee,.123);setRod(l.shin,knee,ankle,.082);l.knee.position.copy(knee);l.foot.position.copy(p).add(V(.018,.093,l.s*.035));l.foot.rotation.z=Math.sin(a)*.12;}153
for(const a of arms){const shoulder=V(-.21,2.52+bob,a.s*.27),elbow=V(.17,2.20+bob*.5,a.s*.38),hand=V(.855,2.075,a.s*.355);const w=Math.max(0,1-(sim-waveUntil)/1.9);if(a.s===1&&w>0){const rise=Math.sin(Math.min(1,w*3)*PI/2);hand.set(.31+Math.sin(sim*14)*.08,2.08+rise*.89,.42);elbow.set(.04,2.42,.42);}setRod(a.up,shoulder,elbow,.092);setRod(a.lo,elbow,hand,.075);a.paw.position.copy(hand);a.paw.rotation.z=w>0?-.5:0;}154
crank.rotation.z=phase;wheels.forEach(w=>w.rotation.z=-distance/.671);cargo.rotation.z=Math.sin(phase*2+.4)*.009;}155
function updateCamera(dt){const lerp=1-Math.exp(-dt*4.5);156
orbitYaw+=Math.atan2(Math.sin(userYaw-orbitYaw),Math.cos(userYaw-orbitYaw))*lerp;orbitElev+=(userElev-orbitElev)*lerp;orbitDist+=(userDist-orbitDist)*lerp;157
const aspect=camera.aspect,fit=aspect<1?1+(.95/aspect-1)*.48:1,dist=orbitDist*fit;158
let look=V(),offset=V();if(view==='wide'){look.copy(rider.position).multiplyScalar(.20).setY(.2);offset.set(Math.sin(orbitYaw)*dist,dist*Math.sin(orbitElev),Math.cos(orbitYaw)*dist);}else{look.copy(rider.position).add(V(0,view==='close'?2.10:1.30,0));offset.set(Math.sin(orbitYaw)*dist*Math.cos(orbitElev),dist*Math.sin(orbitElev),Math.cos(orbitYaw)*dist*Math.cos(orbitElev));offset.applyAxisAngle(V(0,1,0),rider.rotation.y);}159
camGoal.copy(look).add(offset);if(!initializedCamera){camera.position.copy(camGoal);camLook.copy(look);initializedCamera=true;}else{camera.position.lerp(camGoal,1-Math.exp(-dt*5));camLook.lerp(look,1-Math.exp(-dt*7));}camera.lookAt(camLook);}160
function updateLighting(dt){duskMix+=(Number(dusk)-duskMix)*(1-Math.exp(-dt*2));waterUniforms.day.value=duskMix;sun.color.copy(new T.Color('#fff0ce')).lerp(new T.Color('#ffc18e'),duskMix);sun.intensity=3.2-duskMix*1.55;ambient.intensity=2-duskMix*.45;ambient.color.copy(new T.Color('#f9f4dc')).lerp(new T.Color('#bac4ec'),duskMix);fill.intensity=.8+duskMix*.3;const sky=new T.Color('#e1ebd9').lerp(new T.Color('#e8c2b3'),duskMix);scene.background.copy(sky);scene.fog.color.copy(sky);M.cloud.color.copy(new T.Color('#f9f4df')).lerp(new T.Color('#f6d6bd'),duskMix);lampGlows.forEach(m=>m.emissiveIntensity=.1+duskMix*2.8);sun.position.copy(rider.position).add(V(-12,23-duskMix*9,10));sun.target.position.copy(rider.position);}161
function toast(txt){$('#toast').textContent=txt;$('#toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').classList.remove('show'),2800);}162
function setView(v){view=v;document.querySelectorAll('.view').forEach(b=>{const on=b.dataset.view===v;b.classList.toggle('active',on);b.setAttribute('aria-pressed',on);});if(v==='wide'){userDist=64;userElev=.86;userYaw=.18;toast('整条河岸,都在风景里。');}else if(v==='close'){userDist=5.2;userElev=.12;userYaw=.62;}else{userDist=8.4;userElev=.29;userYaw=.47;}}163
function togglePause(){paused=!paused;$('#play').setAttribute('aria-pressed',paused);$('#play').setAttribute('aria-label',paused?'继续':'暂停');$('#play').title=(paused?'继续':'暂停')+' · 空格';$('#play').innerHTML=paused?'<svg viewBox="0 0 24 24"><path d="m8 5 11 7-11 7z" fill="currentColor" stroke="none"/></svg>':'<svg viewBox="0 0 24 24"><path d="M9 5v14M15 5v14" stroke-width="3"/></svg>';if(ambientGain)ambientGain.gain.setTargetAtTime(soundOn&&!paused?.22:0,audio.currentTime,.35);}164
$('#play').onclick=togglePause;document.querySelectorAll('.view').forEach(b=>b.onclick=()=>setView(b.dataset.view));$('#speed').oninput=e=>{targetSpeed=Number(e.target.value);$('#speedtext').textContent=targetSpeed.toFixed(1)+'×';};165
function toggleLight(){dusk=!dusk;document.body.classList.toggle('dusk',dusk);$('#light').classList.toggle('active',dusk);$('#light').setAttribute('aria-pressed',dusk);$('#light').setAttribute('aria-label',dusk?'切换早晨':'切换黄昏');$('#season').textContent=dusk?'初秋 · 温柔的黄昏':'初秋 · 晴朗的早晨';}$('#light').onclick=toggleLight;166
$('#helpbtn').onclick=()=>{const show=$('#help').hidden;$('#help').hidden=!show;$('#helpbtn').setAttribute('aria-expanded',show);};$('#full').onclick=()=>{if(document.fullscreenElement)document.exitFullscreen().catch(()=>{});else document.documentElement.requestFullscreen().catch(()=>toast('当前浏览器不支持全屏。'));};167
$('#capture').onclick=()=>{renderer.render(scene,camera);renderer.domElement.toBlob(blob=>{if(!blob){toast('这次画面未能保存,请再试一次。');return;}const a=document.createElement('a'),u=URL.createObjectURL(blob);a.href=u;a.download='河岸慢递-'+(dusk?'黄昏':'早晨')+'.png';a.click();setTimeout(()=>URL.revokeObjectURL(u),2000);toast('一张河岸明信片,已保存。');},'image/png');};168
// Gesture controls: smooth orbit, pointer capture, two-finger pinch, and tap greeting.169
const pointers=new Map();let downX=0,downY=0,totalDrag=0,pinchDistance=0;const cv=renderer.domElement;170
cv.addEventListener('pointerdown',e=>{cv.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});downX=e.clientX;downY=e.clientY;totalDrag=0;pinchDistance=0;});cv.addEventListener('pointermove',e=>{const p=pointers.get(e.pointerId);if(!p)return;const dx=e.clientX-p.x,dy=e.clientY-p.y;totalDrag+=Math.abs(dx)+Math.abs(dy);p.x=e.clientX;p.y=e.clientY;if(pointers.size===1){userYaw-=dx*.006;userElev=T.MathUtils.clamp(userElev+dy*.004,.04,1.34);}else if(pointers.size===2){const ps=[...pointers.values()],d=Math.hypot(ps[0].x-ps[1].x,ps[0].y-ps[1].y);if(pinchDistance)userDist=T.MathUtils.clamp(userDist*pinchDistance/d,view==='wide'?29:3.5,view==='wide'?110:23);pinchDistance=d;}});171
function finishPointer(e){pointers.delete(e.pointerId);if(e.type==='pointerup'&&totalDrag<7){const ray=new T.Raycaster();ray.setFromCamera(new T.Vector2(e.clientX/innerWidth*2-1,1-e.clientY/innerHeight*2),camera);if(ray.intersectObject(rider,true).length){waveUntil=sim;ringBell();toast('嗨!这份温柔也有你的一份。');}}pinchDistance=0;}172
cv.addEventListener('pointerup',finishPointer);cv.addEventListener('pointercancel',finishPointer);cv.addEventListener('wheel',e=>{e.preventDefault();userDist=T.MathUtils.clamp(userDist*Math.exp(e.deltaY*.001),view==='wide'?29:3.5,view==='wide'?110:23);},{passive:false});cv.addEventListener('contextmenu',e=>e.preventDefault());173
document.addEventListener('keydown',e=>{if(e.target.matches('input')||e.ctrlKey||e.metaKey||e.altKey)return;const k=e.key.toLowerCase();if(k===' '){e.preventDefault();togglePause();}if(k==='r')setView('follow');if(k==='l')toggleLight();if(k==='m')toggleSound();if(k==='b'){ringBell();toast('叮铃,借过一下。');}if(k==='escape'){$('#help').hidden=true;$('#helpbtn').setAttribute('aria-expanded','false');}});174
// Optional Web Audio ambience is synthesized offline and starts only on user gesture.175
let audio=null,ambientGain=null,soundOn=false,nextBird=0;176
function initAudio(){if(audio){audio.resume().catch(()=>{});return;}const A=window.AudioContext||window.webkitAudioContext;if(!A)return;audio=new A();const len=audio.sampleRate*4,buffer=audio.createBuffer(1,len,audio.sampleRate),data=buffer.getChannelData(0);let v=0;for(let i=0;i<len;i++){v=(v+(Math.random()*2-1)*.026)/1.018;data[i]=v*4;}const n=audio.createBufferSource();n.buffer=buffer;n.loop=true;const f=audio.createBiquadFilter();f.type='lowpass';f.frequency.value=1300;ambientGain=audio.createGain();ambientGain.gain.value=0;n.connect(f).connect(ambientGain).connect(audio.destination);n.start();}177
function toggleSound(){initAudio();if(!audio){toast('当前浏览器不支持环境声。');return;}soundOn=!soundOn;$('#sound').classList.toggle('active',soundOn);$('#sound').setAttribute('aria-pressed',soundOn);$('#sound').setAttribute('aria-label',soundOn?'关闭环境声':'开启环境声');$('#sound').innerHTML=soundOn?'<svg viewBox="0 0 24 24"><path d="m11 4-6 5H2v6h3l6 5zM15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/></svg>':'<svg viewBox="0 0 24 24"><path d="m11 4-6 5H2v6h3l6 5zM16 9l5 6m0-6-5 6"/></svg>';ambientGain.gain.setTargetAtTime(soundOn&&!paused?.22:0,audio.currentTime,.4);nextBird=sim+1;}178
$('#sound').onclick=toggleSound;179
function chime(freq,start,duration,gain=.06){if(!audio)return;const o=audio.createOscillator(),g=audio.createGain();o.type='sine';o.frequency.setValueAtTime(freq,start);g.gain.setValueAtTime(.0001,start);g.gain.exponentialRampToValueAtTime(gain,start+.008);g.gain.exponentialRampToValueAtTime(.0001,start+duration);o.connect(g).connect(audio.destination);o.start(start);o.stop(start+duration+.02);}180
function ringBell(){initAudio();if(!audio)return;const now=audio.currentTime;for(let k=0;k<2;k++){chime(1650,now+k*.11,.75,.065);chime(2380,now+k*.11,.42,.022);}}181
function birdSong(){if(!soundOn||!audio||paused)return;const now=audio.currentTime;for(let i=0;i<3;i++){const t=now+i*.13,o=audio.createOscillator(),g=audio.createGain();o.frequency.setValueAtTime(2400+i*180,t);o.frequency.exponentialRampToValueAtTime(3900-i*100,t+.06);o.frequency.exponentialRampToValueAtTime(2500,t+.12);g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(.018,t+.018);g.gain.exponentialRampToValueAtTime(.0001,t+.12);o.connect(g).connect(audio.destination);o.start(t);o.stop(t+.14);}}182
document.addEventListener('visibilitychange',()=>{last=performance.now();if(audio&&ambientGain)ambientGain.gain.setTargetAtTime(document.hidden?0:(soundOn&&!paused?.22:0),audio.currentTime,.2);});183
window.addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});184
let lastLabel=-1,loaded=false;function frame(now){requestAnimationFrame(frame);const realDt=Math.min((now-last)/1000,.05);last=now;const dt=document.hidden?0:realDt;speed+=(targetSpeed-speed)*(1-Math.exp(-dt*2.5));if(!paused){sim+=dt;clothTime+=dt*(.65+.35*speed);distance+=dt*2.5*speed;phase=-distance/.671/1.55;}const angle=angleAtDistance(initialDistance-distance),p=routeAt(angle),tangent=routeAt(angle-.003).sub(routeAt(angle+.003)).normalize();rider.position.copy(p).setY(0);rider.rotation.y=Math.atan2(-tangent.z,tangent.x);bike.rotation.x=Math.sin(phase)*.007;otter.rotation.x=Math.sin(phase)*.008;updateOtter(dt);waterUniforms.time.value=sim;185
ducks.forEach(d=>{d.g.position.copy(d.p).add(V(Math.sin(sim*.22+d.i)*.35,.075+Math.sin(sim*1.6+d.i)*.014,Math.cos(sim*.19+d.i)*.3));d.g.rotation.z=Math.sin(sim*1.2+d.i)*.018;});birds.forEach(b=>{const a=sim*.042+b.i*1.05;b.g.position.set(Math.cos(a)*(19+b.i),7+b.i*.4+Math.sin(sim*.7+b.i)*.3,Math.sin(a)*17);b.g.rotation.y=-a;b.wings.forEach(w=>w.pivot.rotation.x=w.s*Math.sin(sim*7+b.i)*.45);});clouds.rotation.y=sim*.0007;186
updateLighting(dt);updateCamera(dt);renderer.render(scene,camera);if(!loaded){loaded=true;$('#loading').style.opacity=0;setTimeout(()=>$('#loading').remove(),750);}187
if(Math.floor(distance)!==lastLabel){lastLabel=Math.floor(distance);$('#distance').textContent='沿河骑行 '+Math.floor(distance).toString().padStart(3,'0')+' m';$('#progress').style.width=(distance%routeLength)/routeLength*100+'%';}188
if(sim>nextBird){birdSong();nextBird=sim+8+Math.random()*8;}189
// Automatically trim pixel density if sustained frame time is too high.190
if(!document.hidden&&qualitySamples<180){frameMs=frameMs*.98+realDt*1000*.02;qualitySamples++;if(qualitySamples===180&&frameMs>30){renderer.setPixelRatio(Math.min(devicePixelRatio,1.15));sun.shadow.mapSize.set(1024,1024);sun.shadow.map?.dispose();sun.shadow.map=null;renderer.setSize(innerWidth,innerHeight);}}191
}192
// Small read-only diagnostic surface for verifying the delivered offline scene.193
window.__river={get state(){return {time:sim,distance,phase,speed,paused,view,dusk,routeLength,drawCalls:renderer.info.render.calls,triangles:renderer.info.render.triangles};},renderer,scene,camera,rider};requestAnimationFrame(frame);194
})();195
</script>196
</body>197
</html>