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="color-scheme" content="light">7
<title>河岸邮差 · The Riverside Courier</title>8
<style>9
:root{font-family:"Segoe UI","Microsoft YaHei",sans-serif;color:#29473d;--paper:rgba(255,251,237,.89);--ink:#29473d;--accent:#bd6148}10
*{box-sizing:border-box}body{margin:0;overflow:hidden;background:#e3ebdc}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #b96849;outline-offset:4px}#scene{position:fixed;inset:0;touch-action:none;cursor:grab}#scene:active{cursor:grabbing}#scene canvas{display:block;width:100%;height:100%}.vignette{position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at 50% 44%,transparent 48%,rgba(74,69,39,.10) 100%)}11
header{position:fixed;left:clamp(24px,4.5vw,74px);top:clamp(24px,5vh,58px);pointer-events:none}.eyebrow{display:flex;gap:10px;align-items:center;font-size:10px;letter-spacing:3px;font-weight:600;margin-bottom:14px}.eyebrow:before{content:"";width:22px;height:1px;background:#456253}h1{margin:0;font-family:Georgia,"Noto Serif SC","Songti SC",SimSun,serif;font-size:clamp(30px,3.6vw,52px);font-weight:500;letter-spacing:6px;line-height:1.3}header p{font-size:12px;letter-spacing:2px;opacity:.7;margin:14px 0 0}.edition{position:fixed;right:clamp(24px,4vw,60px);top:clamp(24px,5vh,58px);display:flex;align-items:center;gap:10px;font-size:10px;letter-spacing:2px}.edition i{width:7px;height:7px;border-radius:50%;background:#698b65;box-shadow:0 0 0 4px #698b6517}.edition span:last-child{opacity:.65}12
.dock{position:fixed;bottom:28px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:20px;padding:14px 18px;border-radius:20px;background:var(--paper);border:1px solid #fff9;box-shadow:0 8px 45px #344c3820;backdrop-filter:blur(18px);max-width:calc(100% - 32px)}.tools{display:flex;align-items:center;gap:4px}.btn{background:transparent;border:0;color:var(--ink);width:42px;height:42px;border-radius:50%;display:grid;place-items:center;transition:background .2s,transform .2s}.btn:hover{background:#48634d13;transform:translateY(-1px)}.btn.primary{background:#36594b;color:#fff6e2}.btn svg{width:19px;height:19px;stroke:currentColor;stroke-width:1.65;fill:none;stroke-linecap:round;stroke-linejoin:round}.separator{height:32px;width:1px;background:#47634320}.slider{width:124px;flex-shrink:0}.slider label{display:flex;justify-content:space-between;font-size:10px;letter-spacing:1px;margin-bottom:7px}.slider output{opacity:.65;letter-spacing:0}input[type=range]{display:block;appearance:none;width:100%;height:3px;margin:10px 0 5px;border-radius:3px;background:#36594b25;cursor:pointer;accent-color:#b86c4d}input[type=range]::-webkit-slider-thumb{appearance:none;width:12px;height:12px;border-radius:50%;background:#b86c4d;border:2px solid #fff6e2;box-shadow:0 0 0 1px #b86c4d}.caption{position:fixed;bottom:116px;left:50%;transform:translateX(-50%);font-size:10px;letter-spacing:1.5px;color:#325644c9;pointer-events:none;white-space:nowrap;text-shadow:0 1px 8px #ffffef}.caption kbd{font-family:inherit;font-size:9px;border:1px solid #45685135;border-radius:4px;padding:2px 4px;margin:0 4px}.credit{position:fixed;left:clamp(24px,4.5vw,74px);bottom:35px;font-size:9px;letter-spacing:2px;line-height:1.8;color:#3e655ba8;pointer-events:none}.view-label{position:fixed;right:clamp(24px,4vw,60px);bottom:35px;font-size:10px;letter-spacing:2px;color:#3e655b;pointer-events:none}.toast{position:fixed;top:24px;left:50%;transform:translate(-50%,-20px);opacity:0;background:var(--paper);padding:12px 22px;border-radius:30px;font-size:12px;box-shadow:0 5px 30px #29473d13;transition:opacity .3s,transform .3s;pointer-events:none;z-index:5}.toast.show{opacity:1;transform:translate(-50%,0)}#loading{position:fixed;inset:0;display:grid;place-items:center;background:#e9eddf;z-index:8;transition:opacity .6s}.loading-inner{text-align:center}.loading-inner span{display:block;font:28px Georgia,SimSun,serif;letter-spacing:5px;margin-bottom:18px}.loading-inner small{font-size:11px;letter-spacing:3px;color:#638473}.loading-inner i{display:block;width:28px;height:28px;border:1px solid #67876c40;border-top-color:#67876c;border-radius:50%;animation:spin 1.4s linear infinite;margin:26px auto}#error{display:none;position:fixed;inset:0;z-index:10;background:#f4efdc;align-items:center;justify-content:center;text-align:center;padding:32px;line-height:1.9}#error h2{font:26px Georgia,SimSun,serif}#error p{max-width:480px;font-size:14px}#help{position:fixed;right:28px;bottom:118px;width:252px;padding:23px 25px;background:var(--paper);backdrop-filter:blur(20px);border-radius:18px;box-shadow:0 8px 45px #344c3820;font-size:12px;line-height:2;display:none}#help.open{display:block}#help strong{font-weight:500;font-family:SimSun,serif;font-size:19px;letter-spacing:2px;display:block;margin-bottom:12px}#help p{margin:3px 0;color:#4d6b5d}#help kbd{display:inline-block;min-width:43px;color:#bc674b;font:11px "Segoe UI",sans-serif}.night{--paper:rgba(238,239,228,.88)}.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@keyframes spin{to{transform:rotate(360deg)}}13
@media(max-width:850px){.credit,.view-label{display:none}.dock{gap:13px;padding:12px}.slider{width:100px}.tools{gap:0}.btn{width:38px;height:38px}.dock .extras .photo{display:none}}14
@media(max-width:580px){header{top:26px;left:25px}h1{font-size:30px}.eyebrow{font-size:8px;letter-spacing:2px;margin-bottom:10px}header p{font-size:10px;letter-spacing:1px}.edition{right:23px;top:29px;font-size:8px;letter-spacing:1px}.edition span:last-child{display:none}.dock{bottom:18px;gap:10px;padding:12px;flex-wrap:wrap;justify-content:center;width:330px;border-radius:21px}.slider{width:112px}.dock .separator{display:none}.tools{flex:1;justify-content:center}.tools.extras{flex:1}.dock .extras .photo{display:grid}.sliders{display:contents}.slider label{font-size:9px}.caption{bottom:157px;font-size:9px;letter-spacing:.6px}.btn{width:39px;height:39px}#help{bottom:165px;right:18px}.toast{top:110px;font-size:11px}}15
</style>16
</head>17
<body>18
<div id="scene" role="img" aria-label="可交互的三维动画:橘红围巾随风飘动的水獭骑自行车运送包裹,沿着有野花、柳树和小屋的河岸前行。"></div>19
<div class="vignette"></div>20
<header><div class="eyebrow">THE RIVERSIDE COURIER</div><h1>河岸邮差</h1><p>一阵风,一件包裹,慢慢抵达。</p></header>21
<div class="edition"><i></i><span id="light-label">午后 · 晴</span><span>VOL. 01</span></div>22
<div class="caption">拖动环顾 · 滚轮缩放 · 点击水獭打招呼 <kbd>SPACE</kbd> 暂停</div>23
<div class="credit">A LITTLE JOURNEY<br>沿河而行,风也同行。</div><div class="view-label" id="view-label">随行视角</div>24
<div class="dock" aria-label="动画控制">25
<div class="tools">26
<button class="btn primary" id="play" title="暂停 / 继续(空格)" aria-label="暂停动画" aria-pressed="false"><svg id="play-icon" viewBox="0 0 24 24"><path d="M9 5v14M15 5v14"/></svg></button>27
<button class="btn" id="view" title="切换:随行 / 特写 / 远景" aria-label="切换视角"><svg viewBox="0 0 24 24"><path d="M3 8V4h4M17 4h4v4M21 16v4h-4M7 20H3v-4"/><circle cx="12" cy="12" r="4"/></svg></button>28
<button class="btn" id="bell" title="叮铃铃(B)" aria-label="响铃,与水獭打招呼"><svg viewBox="0 0 24 24"><path d="M5 16h14l-2-3V9a5 5 0 0 0-10 0v4l-2 3ZM10 20h4M12 3V2"/></svg></button>29
</div><div class="separator"></div>30
<div class="slider"><label for="speed">骑行速度 <output id="speed-val">悠然</output></label><input id="speed" type="range" min="0.35" max="1.8" step="0.05" value="0.85" aria-label="骑行速度"></div>31
<div class="slider"><label for="wind">河畔的风 <output id="wind-val">轻风</output></label><input id="wind" type="range" min="0" max="1.6" step="0.05" value="0.6" aria-label="风力"></div>32
<div class="separator"></div><div class="tools extras">33
<button class="btn" id="light" title="午后 / 暮色(L)" aria-label="切换光照"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M5 19l1.5-1.5M17.5 6.5 19 5"/></svg></button>34
<button class="btn photo" id="photo" title="保存此刻的风景" aria-label="保存画面为 PNG"><svg viewBox="0 0 24 24"><path d="M3 7h4l2-3h6l2 3h4v13H3Z"/><circle cx="12" cy="13" r="4"/></svg></button>35
<button class="btn" id="help-btn" title="操作说明" aria-label="操作说明" aria-expanded="false"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.5 8a2.6 2.6 0 0 1 5 1c0 2-2.5 2-2.5 4M12 17h.01"/></svg></button>36
</div>37
</div>38
<aside id="help"><strong>风景,慢慢看。</strong><p>拖动画面 · 环顾四周</p><p>滚轮 / 双指 · 拉近拉远</p><p><kbd>SPACE</kbd>暂停与继续</p><p><kbd>R</kbd>回到随行视角</p><p><kbd>B</kbd>响铃打招呼</p><p><kbd>L</kbd>切换午后与暮色</p><p>相机按钮 · 保存无界面的画面</p></aside>39
<div class="toast" id="toast" role="status" aria-live="polite"></div>40
<div id="loading"><div class="loading-inner"><span>河岸邮差</span><small>装好包裹,出发。</small><i></i></div></div>41
<div id="error"><div><h2>风景还在等你</h2><p id="error-text">浏览器未能启动三维画面。请使用支持 WebGL 的现代浏览器,并开启硬件加速后重新打开此文件。</p></div></div>42
<!-- THREE_LIBRARY -->43
<script>44
/* All models, textures, sound and animation are generated here. No external requests. */45
(() => {46
'use strict';47
const $=id=>document.getElementById(id), T=THREE, PI=Math.PI, TAU=PI*2;48
const S={time:0,travel:9,crank:0,speed:.85,wind:.6,paused:false,night:false,light:0,greet:0,view:0};49
let renderer;50
try{renderer=new T.WebGLRenderer({antialias:true,alpha:false,powerPreference:'high-performance'});}catch(e){$('loading').style.display='none';$('error').style.display='flex';return;}51
renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));renderer.setSize(innerWidth,innerHeight);52
renderer.outputColorSpace=T.SRGBColorSpace;renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.02;53
renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;54
$('scene').appendChild(renderer.domElement);55
const scene=new T.Scene();scene.fog=new T.FogExp2(0xdce6d3,.019);56
const camera=new T.PerspectiveCamera(38,innerWidth/innerHeight,.1,180);57
const hemi=new T.HemisphereLight(0xe9f5e9,0x819052,2.3);scene.add(hemi);58
const sun=new T.DirectionalLight(0xffe5b7,3.8);sun.position.set(6,14,8);sun.castShadow=true;59
sun.shadow.mapSize.set(2048,2048);Object.assign(sun.shadow.camera,{left:-19,right:19,top:19,bottom:-19,near:1,far:45});sun.shadow.bias=-.00025;sun.shadow.normalBias=.035;sun.shadow.radius=3;scene.add(sun);scene.add(sun.target);60
const fill=new T.DirectionalLight(0xbbe5dd,.65);fill.position.set(-7,5,-12);scene.add(fill);61
const nightLamp=new T.PointLight(0xffcb76,0,8,2);scene.add(nightLamp);62
const world=new T.Group();scene.add(world);63
const v=(x=0,y=0,z=0)=>new T.Vector3(x,y,z);64
const geo={sphere:new T.SphereGeometry(1,40,28),lowSphere:new T.SphereGeometry(1,14,10),tinySphere:new T.SphereGeometry(1,8,6),ico:new T.IcosahedronGeometry(1,1),box:new T.BoxGeometry(1,1,1),cyl:new T.CylinderGeometry(1,1,1,12),cone:new T.ConeGeometry(1,1,12)};65
{const a=[],indices=[];for(let i=0;i<4;i++){const ang=i*2.399,ca=Math.cos(ang),sa=Math.sin(ang),h=.6+i*.13,k=a.length/3;for(const [w,y,b] of [[-.12,0,0],[.12,0,0],[-.075,h*.5,.12],[.075,h*.5,.12],[0,h,.32]])a.push(w*ca+b*sa,y,w*sa-b*ca);indices.push(k,k+2,k+1,k+1,k+2,k+3,k+2,k+4,k+3);}const g=new T.BufferGeometry();g.setAttribute('position',new T.Float32BufferAttribute(a,3));g.setIndex(indices);g.computeVertexNormals();geo.tuft=g;}66
const mat={};67
function material(name,color,roughness=.82,metalness=0){return mat[name]=new T.MeshStandardMaterial({color,roughness,metalness});}68
material('fur',0x7b513b);material('furLight',0x96704d);material('cream',0xdcc3a0);material('muzzle',0xefdab8);material('ear',0xba8060);material('nose',0x342b25,.4);material('eye',0x191d1a,.16);material('iris',0x76502c,.36);material('white',0xfff8e5,.35);material('scarf',0xc35b3d);material('bike',0x317e74,.34,.28);material('trim',0xacc5ac,.3,.4);material('metal',0xc5c3a4,.34,.73);material('brass',0xb99850,.34,.55);material('rubber',0x303c38,.86);material('leather',0x765240);material('parcel',0xc4975e);material('parcelLight',0xdeb47a);material('parcelBlue',0x91a99a);material('rope',0xf0dcb1);material('grass',0x91aa68);material('leaf',0x699572);material('leafLight',0x8eab70);material('leafDark',0x497c60);material('trunk',0x897355);material('rock',0xa6aaa0);material('sand',0xc9bc8d);material('path',0xd6c29a);material('stem',0x5f8253);material('pink',0xe4a298);material('yellow',0xefcf80);material('flowerWhite',0xf5ecd2);material('house',0xeddec0);material('roof',0xb47757);material('window',0x42675c,.3);material('fence',0xcbb994);material('duck',0xe6dbc0);material('duckHead',0x416e5c);material('duckBeak',0xdca751);material('hill',0x91ac88);material('red',0xab4e35);69
const animatedUniforms={time:{value:0},wind:{value:.6}};70
for(const n of ['leaf','leafLight','leafDark','stem']){mat[n].onBeforeCompile=shader=>{shader.uniforms.uTime=animatedUniforms.time;shader.uniforms.uWind=animatedUniforms.wind;shader.vertexShader='uniform float uTime;uniform float uWind;\n'+shader.vertexShader;shader.vertexShader=shader.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\n transformed.x += sin(uTime*1.8 + position.y*3.0 + position.x*2.0)*max(position.y,0.0)*0.038*uWind; transformed.z += cos(uTime*1.3+position.y*4.0)*max(position.y,0.0)*0.027*uWind;');};}71
mat.stem.side=T.DoubleSide;72
for(const n of ['grass','path','sand'])mat[n].onBeforeCompile=shader=>{shader.vertexShader='varying vec3 vTerrain;\n'+shader.vertexShader;shader.vertexShader=shader.vertexShader.replace('#include <begin_vertex>','#include <begin_vertex>\n vTerrain=position;');shader.fragmentShader='varying vec3 vTerrain;\n'+shader.fragmentShader;shader.fragmentShader=shader.fragmentShader.replace('#include <color_fragment>','#include <color_fragment>\n float mottling=sin(vTerrain.x*.83+sin(vTerrain.z*1.3))*sin(vTerrain.z*.7)+sin(vTerrain.x*3.2+vTerrain.z*2.7)*.13; diffuseColor.rgb*=.96+mottling*.045;');};73
function mesh(g,m,p,s,parent=world){const o=new T.Mesh(typeof g==='string'?geo[g]:g,typeof m==='string'?mat[m]:m);if(p)o.position.copy(p);if(s)o.scale.copy(s);o.castShadow=true;o.receiveShadow=true;parent.add(o);return o;}74
function ell(m,x,y,z,sx,sy,sz,parent){return mesh('sphere',m,v(x,y,z),v(sx,sy,sz),parent);}75
function box(m,x,y,z,sx,sy,sz,parent){return mesh('box',m,v(x,y,z),v(sx,sy,sz),parent);}76
const axisY=v(0,1,0);77
function link(a,b,r,m,parent){const o=mesh('cyl',m,a.clone().add(b).multiplyScalar(.5),v(r,a.distanceTo(b),r),parent);o.quaternion.setFromUnitVectors(axisY,b.clone().sub(a).normalize());return o;}78
function tube(points,r,m,parent,closed=false){const c=new T.CatmullRomCurve3(points.map(p=>Array.isArray(p)?v(...p):p),closed);const o=mesh(new T.TubeGeometry(c,Math.max(12,points.length*8),r,7,closed),m,null,null,parent);return o;}79
function torus(r,thick,m,x,y,z,parent,arc=TAU){return mesh(new T.TorusGeometry(r,thick,10,64,arc),m,v(x,y,z),null,parent);}80
let seed=31893;function rand(){seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;}const range=(a,b)=>a+rand()*(b-a);81
function path(x){return Math.sin(TAU*x/120)*1.65+Math.sin(TAU*x/60)*.38;}82
function slope(x){return Math.cos(TAU*x/120)*1.65*TAU/120+Math.cos(TAU*x/60)*.38*TAU/60;}83
function landHeight(x,offset){const hill=offset < -15 ? Math.min(2.2,(-offset-15)*.085) : offset>12? Math.min(1.2,(offset-12)*.025):0;return .005+hill*(.65+.35*Math.sin(x*.10+offset*.08));}84
function strip(a,b,materialName,yFunc,segments=300,across=1){const pos=[],uv=[],idx=[];for(let i=0;i<=segments;i++){const x=i*120/segments-60;for(let j=0;j<=across;j++){const off=a+(b-a)*j/across;pos.push(x,yFunc(x,off),path(x)+off);uv.push(i/segments,j/across);}}for(let i=0;i<segments;i++)for(let j=0;j<across;j++){let k=i*(across+1)+j;idx.push(k,k+1,k+across+1,k+1,k+across+2,k+across+1);}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();const o=new T.Mesh(g,typeof materialName==='string'?mat[materialName]:materialName);o.receiveShadow=true;return o;}85
// A periodic landscape is recycled as one continuous 120-metre riverside route.86
const tile=new T.Group();87
tile.add(strip(-52,-11.45,'grass',landHeight,300,14));tile.add(strip(-4.1,42,'grass',landHeight,300,12));88
tile.add(strip(-11.45,-10.8,'sand',(x,z)=>.005-(z+11.45)/.65*.32));tile.add(strip(-4.8,-4.1,'sand',(x,z)=>-.315+(z+4.8)/.7*.32));89
tile.add(strip(-1.5,1.5,'path',()=>.023,300));tile.add(strip(-1.65,-1.5,'sand',()=>.021));tile.add(strip(1.5,1.65,'sand',()=>.021));90
const waterUniforms={uTime:{value:0},uDusk:{value:0}};91
const waterMat=new T.ShaderMaterial({uniforms:waterUniforms,vertexShader:`varying vec3 w; varying vec3 n; uniform float uTime; void main(){ vec3 p=position; p.y+=sin(p.x*1.4+p.z*1.8-uTime*1.1)*.012+sin(p.x*2.6-p.z*1.2+uTime*.8)*.008; vec4 wp=modelMatrix*vec4(p,1.); w=wp.xyz; n=vec3(0.,1.,0.); gl_Position=projectionMatrix*viewMatrix*wp; }`,fragmentShader:`varying vec3 w; uniform float uTime; uniform float uDusk; void main(){float a=sin(w.x*2.1+w.z*3.6-uTime*1.3); float b=sin(w.x*3.1-w.z*2.4+uTime*.85); float r=pow(max(0.,a*b),16.); float c=sin(w.x*.42+w.z*.9+uTime*.2)*.5+.5; vec3 col=mix(vec3(.23,.57,.52),vec3(.43,.69,.60),c); col+=r*vec3(.36,.35,.23); float glitter=pow(max(0.,sin(w.x*6.5+w.z*11.+uTime*1.8)),30.)*pow(max(0.,sin(w.z*4.8-w.x*2.)),8.); col+=glitter*.19; col=mix(col,col*vec3(.63,.77,1.06),uDusk); float fog=1.-exp(-.00036*dot(w-cameraPosition,w-cameraPosition)); col=mix(col,mix(vec3(.72,.79,.66),vec3(.38,.49,.57),uDusk),fog); gl_FragColor=vec4(col,1.); \n #include <tonemapping_fragment> \n #include <colorspace_fragment> }`});92
tile.add(strip(-10.8,-4.8,waterMat,()=>-.19,360,20));93
// Static scenery is batched into instanced meshes to keep rendering light.94
const batches={};const dummy=new T.Object3D();95
function emit(g,m,x,y,z,sx=1,sy=1,sz=1,rx=0,ry=0,rz=0){if(g==='sphere')g=['pink','yellow','flowerWhite'].includes(m)?'tinySphere':'lowSphere';const key=g+'|'+m;(batches[key]||(batches[key]=[])).push([x,y,z,sx,sy,sz,rx,ry,rz]);}96
function rod(m,a,b,r){const d=b.clone().sub(a),q=new T.Quaternion().setFromUnitVectors(axisY,d.normalize()),e=new T.Euler().setFromQuaternion(q);emit('cyl',m,(a.x+b.x)/2,(a.y+b.y)/2,(a.z+b.z)/2,r,a.distanceTo(b),r,e.x,e.y,e.z);}97
function tree(x,off,s=1,type=0){const z=path(x)+off,y=landHeight(x,off);emit('cyl','trunk',x,y+1.3*s,z,.14*s,2.6*s,.14*s,0,0,.035);if(type===1){for(let j=0;j<4;j++){emit('sphere',j%2?'leaf':'leafLight',x+Math.sin(j*2)*.14*s,y+(1.5+j*.55)*s,z,.65*s,.94*s,.6*s);}}else if(type===2){for(let j=0;j<3;j++)emit('cone',j%2?'leafDark':'leaf',x,y+(1.5+j*.64)*s,z,(1.04-j*.18)*s,1.9*s,(1.04-j*.18)*s);}else{for(let j=0;j<7;j++){const ang=j*2.399,r=j===0?0:.65*s;const bx=x+Math.sin(ang)*r,bz=z+Math.cos(ang)*r,by=y+(2.25+(j%3)*.3)*s;emit('sphere',j%3===0?'leafLight':j%3===1?'leaf':'leafDark',bx,by,bz,.92*s,.85*s,.79*s);if(j%2===0)rod('trunk',v(x,y+1.2*s,z),v(bx,by,bz),.065*s);}}}98
for(let i=0;i<58;i++){const x=range(-59,59),off=i<24?range(-24,-12.4):range(13,25);tree(x,off,range(.65,1.5),i%7===0?2:i%4===0?1:0);}99
// Willow beside the water, long cascades of leaf clusters.100
for(const x of [-34,24]){const z=path(x)-12,y=.04;emit('cyl','trunk',x,y+1.6,z,.22,3.2,.22,0,0,-.15);for(let j=0;j<9;j++){let a=j*TAU/9,bx=x+Math.sin(a)*1.25,bz=z+Math.cos(a)*1.25;rod('trunk',v(x,2.2,z),v(bx,3.3,bz),.08);emit('sphere','leafLight',bx,3.45,bz,.86,.46,.85);for(let k=0;k<5;k++)emit('sphere',k%2?'leaf':'leafLight',bx+Math.sin(a)*k*.11,3.3-k*.38,bz+Math.cos(a)*k*.1,.3,.48,.28);}}101
// Stones, tufts and a continuous scatter of meadow flowers.102
for(let i=0;i<135;i++){const x=range(-60,60),off=i%2?range(-4.2,-3.6):range(-12,-11.5);emit('ico','rock',x,.08,path(x)+off,range(.15,.5),range(.1,.27),range(.16,.4),0,rand()*TAU,range(-.2,.2));}103
for(let i=0;i<2400;i++){const x=range(-60,60),off=i<1900?range(1.9,8):range(-4,-1.85);const z=path(x)+off,y=landHeight(x,off),h=range(.13,.38);emit('tuft',i%3?'stem':'leafLight',x,y,z,.23,h,.23,range(-.15,.15),rand()*TAU,range(-.22,.22));}104
for(let i=0;i<285;i++){const x=range(-60,60),off=range(2.15,8.5),y=landHeight(x,off),z=path(x)+off,h=range(.16,.40);emit('cyl','stem',x,y+h/2,z,.009,h,.009);const cm=i%5===0?'pink':i%3===0?'yellow':'flowerWhite';for(let k=0;k<5;k++){const a=k*TAU/5;emit('sphere',cm,x+Math.sin(a)*.05,y+h,z+Math.cos(a)*.05,.055,.027,.038,0,-a,0);}emit('sphere','yellow',x,y+h+.025,z,.03,.021,.03);}105
for(let i=0;i<80;i++){const x=range(-60,60),off=range(-4.6,-4.05),z=path(x)+off,h=range(.36,.7);emit('cyl','stem',x,h*.5-.08,z,.012,h,.012,0,0,.08);emit('sphere','trunk',x+.02,h-.1,z,.045,.12,.045);}106
// Two tiny riverside homes, a windmill and an old postbox along the route.107
function cottage(x,off,s,ry=0){const z=path(x)+off,y=landHeight(x,off);const g=new T.Group();g.position.set(x,y,z);g.rotation.y=ry;g.scale.setScalar(s);tile.add(g);box('house',0,.8,0,2.3,1.6,1.6,g);const roofShape=new T.Shape();roofShape.moveTo(-1.4,0);roofShape.lineTo(0,.96);roofShape.lineTo(1.4,0);roofShape.closePath();const rg=new T.ExtrudeGeometry(roofShape,{depth:2,bevelEnabled:false});mesh(rg,'roof',v(0,1.58,-1),null,g);box('leather',.55,.50,.817,.42,1.0,.028,g);for(const xx of [-.68,.55]){box('window',xx,1.1,.828,.43,.42,.028,g);box('house',xx,1.1,.85,.045,.42,.035,g);box('house',xx,1.1,.85,.43,.045,.035,g);}box('house',.75,2.2,-.36,.28,.9,.3,g);box('trunk',.75,2.64,-.36,.38,.1,.4,g);box('trunk',0,.12,1.05,2.3,.12,.38,g);for(let i=0;i<9;i++)box('fence',-1.7+i*.42,.34,2,.07,.65,.07,g);box('fence',0,.42,2,3.6,.08,.06,g);}108
cottage(-7,-18,1.15,-.15);cottage(46,-15,.9,.12);cottage(-45,17,.8,PI);109
const post=new T.Group();post.position.set(12,.03,path(12)+2.3);tile.add(post);link(v(0,0,0),v(0,.92,0),.07,'trunk',post);box('red',0,.96,0,.35,.42,.35,post);box('metal',0,1.13,.18,.22,.028,.012,post);box('house',0,.94,.184,.19,.12,.014,post);110
for(let i=0;i<15;i++){const x=15+i*.55,z=path(x)-3.5;emit('box','fence',x,.4,z,.07,.8,.07);if(i<14){const x2=x+.55;rod('fence',v(x,.48,z),v(x2,.48,path(x2)-3.5),.027);rod('fence',v(x,.22,z),v(x2,.22,path(x2)-3.5),.025);}}111
for(let i=0;i<9;i++){const x=-60+i*15,z=-32-rand()*10;emit('sphere','hill',x,.1,z,range(8,13),range(3,5),range(7,12));}112
for(const [key,data] of Object.entries(batches)){const [gn,mn]=key.split('|');const inst=new T.InstancedMesh(geo[gn],mat[mn],data.length);for(let i=0;i<data.length;i++){const p=data[i];dummy.position.set(...p.slice(0,3));dummy.scale.set(...p.slice(3,6));dummy.rotation.set(...p.slice(6,9));dummy.updateMatrix();inst.setMatrixAt(i,dummy.matrix);}inst.castShadow=mn!=='stem'&&mn!=='flowerWhite'&&mn!=='yellow';inst.receiveShadow=true;tile.add(inst);}113
const tiles=[];for(let k=-1;k<=1;k++){const g=k===0?tile:tile.clone(true);g.position.x=k*120;world.add(g);tiles.push({g,k});}114
// Soft sky and clouds use no image textures.115
const skyU={dusk:{value:0}};116
const skyMat=new T.ShaderMaterial({side:T.BackSide,depthWrite:false,uniforms:skyU,vertexShader:'varying vec3 p; void main(){p=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',fragmentShader:'varying vec3 p; uniform float dusk; void main(){float h=normalize(p).y; vec3 day=mix(vec3(.94,.91,.75),vec3(.49,.70,.69),smoothstep(-.12,.72,h)); vec3 night=mix(vec3(.78,.65,.58),vec3(.23,.35,.46),smoothstep(-.12,.75,h)); gl_FragColor=vec4(mix(day,night,dusk),1.); \n #include <colorspace_fragment> }'});117
const sky=new T.Mesh(new T.SphereGeometry(140,28,16),skyMat);scene.add(sky);118
const cloudMat=new T.MeshBasicMaterial({color:0xf5eed8,transparent:true,opacity:.46,depthWrite:false});119
const clouds=new T.Group();scene.add(clouds);for(let i=0;i<14;i++){const x=range(-75,75),y=range(13,27),z=range(-72,-40);for(let j=0;j<4;j++){const c=ell(cloudMat,x+j*2,y+Math.sin(j)*.6,z,2.8,1.1,1.5,clouds);c.castShadow=false;c.receiveShadow=false;}}120
const sunDisc=ell(new T.MeshBasicMaterial({color:0xffe6ac}),-39,18,-68,3,3,3,scene);sunDisc.castShadow=false;121
// Contact shade is a locally generated radial canvas texture.122
const sc=document.createElement('canvas');sc.width=sc.height=128;const cx=sc.getContext('2d'),gr=cx.createRadialGradient(64,64,2,64,64,63);gr.addColorStop(0,'rgba(30,44,27,.28)');gr.addColorStop(.5,'rgba(30,44,27,.11)');gr.addColorStop(1,'rgba(30,44,27,0)');cx.fillStyle=gr;cx.fillRect(0,0,128,128);123
const shadeMat=new T.MeshBasicMaterial({map:new T.CanvasTexture(sc),transparent:true,depthWrite:false});124
const hero=new T.Group();scene.add(hero);125
for(const x of [-.93,1.04]){const o=mesh(new T.PlaneGeometry(1,1),shadeMat,v(x,.029,0),v(.55,.42,1),hero);o.rotation.x=-PI/2;o.castShadow=false;}126
const bike=new T.Group();hero.add(bike);127
const wheels=[];128
function wheel(x){const root=new T.Group();root.position.set(x,.64,0);bike.add(root);const spin=new T.Group();root.add(spin);wheels.push(spin);torus(.586,.056,'rubber',0,0,0,spin);for(const z of [-.045,.045])torus(.581,.011,'cream',0,0,z,spin);torus(.53,.017,'metal',0,0,0,spin);const hub=link(v(0,0,-.065),v(0,0,.065),.065,'metal',spin);for(let j=0;j<20;j++){const a=j*TAU/20;link(v(.04*Math.cos(a+1),.04*Math.sin(a+1),j%2?.032:-.032),v(.53*Math.cos(a),.53*Math.sin(a),0),.009,'metal',spin);}torus(.10,.016,'brass',0,0,.07,spin);return root;}129
wheel(-.93);const frontRoot=wheel(1.04);130
const back=v(-.93,.64,0),bb=v(-.08,.67,0),seat=v(-.35,1.46,0),head=v(.65,1.46,0),front=v(1.04,.64,0);131
for(const [a,b] of [[bb,seat],[seat,head],[bb,head]])link(a,b,.047,'bike',bike);132
for(const z of [-.075,.075]){link(v(back.x,back.y,z),v(bb.x,bb.y,z),.025,'bike',bike);link(v(back.x,back.y,z),v(seat.x,seat.y,z),.025,'bike',bike);link(v(.65,1.5,z),v(1.04,.64,z),.036,'bike',bike);}133
link(v(-.35,1.44,0),v(-.40,1.66,0),.038,'metal',bike);ell('leather',-.41,1.67,0,.24,.069,.18,bike);134
link(v(.65,1.46,0),v(.60,1.74,0),.036,'metal',bike);135
tube([[.60,1.70,0],[.71,1.81,0],[.83,1.85,.25],[.90,1.88,.38]],.028,'metal',bike);tube([[.71,1.81,0],[.83,1.85,-.25],[.90,1.88,-.38]],.028,'metal',bike);136
for(const z of [-.38,.38])link(v(.82,1.88,z),v(.99,1.88,z),.042,'leather',bike);137
tube([[.94,1.83,.33],[.72,1.65,.15],[.66,1.11,.10],[1.03,.79,.07]],.008,'rubber',bike);138
function fender(x){const pts=[];for(let j=0;j<=24;j++){const a=.12+ j/24*(PI-.24);pts.push(v(x+.665*Math.cos(a),.64+.665*Math.sin(a),0));}tube(pts,.041,'bike',bike);for(const z of [-.08,.08]){link(v(x,.64,z),v(x-.48,1.03,z),.009,'metal',bike);}}139
fender(-.93);fender(1.04);140
for(const z of [-.24,.24]){link(v(-1.22,1.37,z),v(-.61,1.37,z),.018,'metal',bike);link(v(-1.19,1.37,z),v(-.93,.66,z*.3),.015,'metal',bike);link(v(-.66,1.37,z),v(-.93,.66,z*.3),.015,'metal',bike);}for(let i=0;i<4;i++)link(v(-1.23+i*.2,1.37,-.25),v(-1.23+i*.2,1.37,.25),.013,'metal',bike);141
const headlight=ell('brass',.89,1.50,0,.105,.096,.092,bike);ell(new T.MeshStandardMaterial({color:0xffedb5,emissive:0xffcf78,emissiveIntensity:.1,roughness:.4}),.975,1.50,0,.025,.073,.073,bike);142
ell('red',-1.38,1.27,0,.022,.062,.073,bike);ell('brass',.80,1.89,.13,.057,.037,.057,bike);143
const chainring=torus(.22,.018,'metal',bb.x,bb.y,.08,bike);torus(.08,.014,'metal',-.93,.64,.08,bike);144
tube([[-.95,.73,.085],[-.08,.9,.085],[.15,.67,.085],[-.08,.45,.085],[-.95,.56,.085]],.012,'rubber',bike,true);145
const crankArms=[],pedals=[];for(const z of [-.23,.23]){const root=new T.Group();root.position.set(bb.x,bb.y,z*.55);bike.add(root);link(v(0,0,0),v(.24,0,0),.025,'metal',root);link(v(.24,0,0),v(.24,0,Math.sign(z)*.1435),.018,'metal',root);crankArms.push(root);const pedal=box('rubber',0,0,z,.19,.052,.15,bike);pedals.push(pedal);}146
// Parcels with paper texture, crossed twine, bows and a hand-drawn address tag.147
const parcels=new T.Group();parcels.position.set(-.96,1.40,0);bike.add(parcels);148
function parcel(m,x,y,z,sx,sy,sz,angle){const g=new T.Group();g.position.set(x,y,z);g.rotation.y=angle;parcels.add(g);box(m,0,sy*.5,0,sx,sy,sz,g);box('parcelLight',0,sy+.002,0,.085,.008,sz+.01,g);for(const zz of [-sz*.5-.006,sz*.5+.006])box('rope',0,sy*.5,zz,.017,sy+.012,.009,g);for(const yy of [.005,sy+.007]){box('rope',0,yy,0,.017,.012,sz+.014,g);box('rope',0,yy,0,sx+.014,.012,.017,g);}for(const xx of [-sx*.5-.006,sx*.5+.006])box('rope',xx,sy*.5,0,.009,sy+.012,.017,g);149
tube([[0,sy+.02,0],[-.08,sy+.07,-.04],[-.13,sy+.07,.02],[0,sy+.02,0],[.08,sy+.09,.05],[.12,sy+.045,0],[0,sy+.02,0]],.009,'rope',g);return g;}150
const p1=parcel('parcel',0,0,0,.70,.40,.55,.02);const p2=parcel('parcelBlue',-.02,.41,.02,.48,.24,.42,-.12);151
const tc=document.createElement('canvas');tc.width=256;tc.height=160;const ct=tc.getContext('2d');ct.fillStyle='#eadabd';ct.fillRect(0,0,256,160);ct.strokeStyle='#a17e51';ct.lineWidth=3;ct.strokeRect(10,10,236,140);ct.fillStyle='#786849';ct.font='18px Georgia';ct.fillText('RIVER POST',28,38);ct.fillStyle='#829471';ct.fillRect(190,24,40,44);ct.strokeStyle='#8b7960';for(let i=0;i<3;i++){ct.beginPath();ct.moveTo(30,75+i*22);ct.lineTo(159-i*18,75+i*22);ct.stroke();}ct.strokeStyle='#a7654c';ct.beginPath();ct.arc(199,113,22,0,TAU);ct.stroke();ct.font='12px Georgia';ct.fillStyle='#a7654c';ct.fillText('01',192,118);152
const tagTexture=new T.CanvasTexture(tc);tagTexture.colorSpace=T.SRGBColorSpace;const tagMat=new T.MeshStandardMaterial({map:tagTexture,roughness:.9});mesh(new T.PlaneGeometry(.30,.19),tagMat,v(.16,.23,.28),null,p1);153
// The otter is built with round organic forms, a long tapered tail and a warm muzzle.154
const otter=new T.Group();hero.add(otter);155
const torso=new T.Group();otter.add(torso);156
ell('fur',-.29,2.10,0,.40,.59,.32,torso).rotation.z=-.18;157
ell('cream',-.03,2.07,.03,.205,.44,.285,torso).rotation.z=-.22;158
ell('fur',-.43,1.79,0,.35,.26,.32,torso);159
const headGroup=new T.Group();headGroup.position.set(.015,2.69,0);otter.add(headGroup);160
ell('fur',0,0,0,.465,.375,.355,headGroup);ell('cream',.20,-.08,0,.295,.265,.337,headGroup);161
for(const z of [-.32,.32]){ell('fur',-.22,.235,z,.10,.103,.083,headGroup);ell('ear',-.20,.24,z*1.14,.059,.060,.018,headGroup);}162
for(const z of [-.12,.12])ell('muzzle',.365,-.12,z,.225,.147,.163,headGroup);163
ell('nose',.558,-.074,0,.071,.052,.074,headGroup);164
const eyes=[];for(const side of [-1,1]){const z=side*.285;const eg=new T.Group();eg.position.set(.265,.105,z);eg.rotation.y=side*.17;headGroup.add(eg);ell('iris',0,0,0,.074,.082,.032,eg);ell('eye',.009,.003,side*.020,.061,.067,.034,eg);ell('white',.015,.028,side*.049,.018,.020,.009,eg);ell('white',.033,-.013,side*.049,.007,.008,.007,eg);eyes.push(eg);tube([[.195,.216,z],[.24,.236,z*1.04],[.295,.217,z]],.014,'furLight',headGroup);}165
link(v(.567,-.12,0),v(.567,-.174,0),.011,'nose',headGroup);166
for(const side of [-1,1]){tube([[.532,-.174,side*.028],[.462,-.215,side*.10],[.375,-.187,side*.23]],.009,'leather',headGroup);for(let i=0;i<3;i++){tube([[.39,-.11-i*.038,side*.225],[.40+i*.018,-.10-i*.052,side*.39],[.33+i*.055,-.035-i*.073,side*(.54+i*.03)]],.0055,'muzzle',headGroup);ell('furLight',.447,-.086-i*.032,side*(.215-i*.012),.011,.011,.008,headGroup);}}167
const tailGeo=new T.BufferGeometry();const tailCount=30,tailSides=12,tailPos=new Float32Array((tailCount+1)*(tailSides+1)*3),tailIdx=[];168
for(let i=0;i<tailCount;i++)for(let j=0;j<tailSides;j++){let k=i*(tailSides+1)+j;tailIdx.push(k,k+tailSides+1,k+1,k+1,k+tailSides+1,k+tailSides+2);}tailGeo.setAttribute('position',new T.BufferAttribute(tailPos,3));tailGeo.setIndex(tailIdx);const tail=mesh(tailGeo,'fur',null,null,otter);tail.frustumCulled=false;169
function updateTail(t){for(let i=0;i<=tailCount;i++){const u=i/tailCount,r=.205*Math.pow(1-u,1.15)+.013,c=v(-.57-u*1.26,1.78-u*.65+Math.sin(u*4-t*3)*.05*u,.25+.20*(1-Math.exp(-u*8))+Math.sin(u*4-t*2.4)*.09*u);for(let j=0;j<=tailSides;j++){const a=j/tailSides*TAU,k=(i*(tailSides+1)+j)*3;tailPos[k]=c.x;tailPos[k+1]=c.y+Math.cos(a)*r;tailPos[k+2]=c.z+Math.sin(a)*r*.79;}}tailGeo.attributes.position.needsUpdate=true;tailGeo.computeVertexNormals();}170
// Limb segments are posed with inverse kinematics, keeping paws on the pedals.171
function limb(m,r){const o=mesh('sphere',m,null,v(r,1,r),otter);return o;}172
function poseLink(o,a,b,r){o.position.copy(a).add(b).multiplyScalar(.5);const d=b.clone().sub(a);o.quaternion.setFromUnitVectors(axisY,d.clone().normalize());o.scale.set(r,d.length()*.57,r);}173
const legs=[],arms=[];for(const side of [-1,1]){const foot=ell('furLight',0,0,0,.19,.085,.12,otter);legs.push({side,upper:limb('fur',.135),lower:limb('fur',.10),knee:ell('fur',0,0,0,.131,.13,.125,otter),foot});const paw=ell('furLight',.89,1.89,side*.38,.116,.09,.079,otter);arms.push({side,upper:limb('fur',.105),lower:limb('fur',.085),elbow:ell('fur',0,0,0,.096,.10,.096,otter),paw});for(let j=0;j<3;j++)tube([[.51,-.32,(j-1)*.30],[.70,-.24,(j-1)*.30],[.84,-.13,(j-1)*.30]],.018,'leather',foot);}174
function solveKnee(hip,foot,l1=.56,l2=.54){const d=foot.clone().sub(hip),len=Math.min(l1+l2-.01,d.length()),dir=d.normalize(),a=(l1*l1-l2*l2+len*len)/(2*len),h=Math.sqrt(Math.max(0,l1*l1-a*a)),perp=v(-dir.y,dir.x,0).normalize();if(perp.x<0)perp.negate();return hip.clone().addScaledVector(dir,a).addScaledVector(perp,h);}175
// A double-sided cloth ribbon, attached at the collar and lifted by relative wind.176
const collar=torus(.307,.071,'scarf',-.12,2.43,0,otter);collar.rotation.x=PI/2;collar.scale.set(1.15,1,1);177
ell('scarf',-.34,2.45,.23,.12,.10,.11,otter);178
const ribbons=[];const scarfMat=new T.MeshStandardMaterial({color:0xffffff,roughness:.9,side:T.DoubleSide,vertexColors:true});179
for(let k=0;k<2;k++){const n=34,verts=new Float32Array((n+1)*3*3),colors=[],indices=[];for(let i=0;i<=n;i++){const cream=i>26&&(i===28||i===29||i===33||i===34);const co=new T.Color(cream?0xe9c995:0xc55d3e);for(let j=0;j<3;j++)colors.push(co.r,co.g,co.b);}for(let i=0;i<n;i++)for(let j=0;j<2;j++){const a=i*3+j;indices.push(a,a+3,a+1,a+1,a+3,a+4);}const g=new T.BufferGeometry();g.setAttribute('position',new T.BufferAttribute(verts,3));g.setAttribute('color',new T.Float32BufferAttribute(colors,3));g.setIndex(indices);const o=mesh(g,scarfMat,null,null,otter);o.frustumCulled=false;ribbons.push({g,verts,n,k});}180
function scarfPoint(u,k,t){const relative=.28+S.wind*.6+S.speed*.18;const length=k===0?1.52:1.17;return v(-.35-u*length,2.47+Math.sin(u*4.2-t*(3.5+S.wind))*.09*u*(.4+relative)+u*.18*relative-u*u*.10,.20+k*.12+Math.sin(u*5.4-t*3.7+k)*.08*u*(.3+relative));}181
const fringes=[];for(let k=0;k<2;k++)for(let j=0;j<6;j++)fringes.push({k,j,o:mesh('cyl','scarf',null,null,otter)});182
function updateScarf(t){for(const r of ribbons){for(let i=0;i<=r.n;i++){const u=i/r.n,p=scarfPoint(u,r.k,t),width=(.25-r.k*.025)*(1-u*.30);for(let j=0;j<3;j++){const a=(i*3+j)*3,edge=(j-1)*width*.5;r.verts[a]=p.x+edge*.08;r.verts[a+1]=p.y+edge*(.85+Math.sin(u*5-t*2)*.35);r.verts[a+2]=p.z+edge*.6;}}r.g.attributes.position.needsUpdate=true;r.g.computeVertexNormals();}for(const f of fringes){const p=scarfPoint(1,f.k,t);const edge=(f.j/5-.5)*.17;p.z+=edge*.6;p.y+=edge*(.85+Math.sin(5-t*2)*.35);const end=p.clone().add(v(-.075,Math.sin(t*4+f.j)*.015,Math.sin(t*3+f.j)*.012));f.o.position.copy(p).add(end).multiplyScalar(.5);f.o.scale.set(.0055,p.distanceTo(end),.0055);f.o.quaternion.setFromUnitVectors(axisY,end.sub(p).normalize());}}183
// Ducks, lilies, drifting seeds and distant birds complete the river.184
const ducks=[];for(let i=0;i<9;i++){const g=new T.Group();const x=-45+i*12,z=path(x)-7.5+(i%3-1)*.64;g.position.set(x,-.12,z);g.rotation.y=i%2?PI:.2;world.add(g);ell('duck',0,0,0,.27,.16,.17,g);ell('duckHead',.18,.17,0,.12,.13,.11,g);ell('duckBeak',.29,.15,0,.09,.038,.046,g);ell('eye',.228,.205,.086,.012,.012,.012,g);ell('eye',.228,.205,-.086,.012,.012,.012,g);ell('parcelLight',-.05,.065,.139,.16,.061,.024,g);const wake=new T.Group();g.add(wake);for(let j=0;j<3;j++){const w=torus(.34+j*.14,.006,new T.MeshBasicMaterial({color:0xcbe4c6,transparent:true,opacity:.22-j*.045}),-.10,-.057,0,wake);w.rotation.x=-PI/2;w.scale.set(1.6,1,1);w.castShadow=false;}ducks.push({g,x,z,wake});}185
const lilies=new T.Group();world.add(lilies);for(let i=0;i<36;i++){const x=range(-58,58),z=path(x)+range(-10.5,-9.4);const p=mesh(new T.CircleGeometry(range(.10,.23),18,0,TAU-.38),'leafDark',v(x,-.163,z),null,lilies);p.rotation.x=-PI/2;p.rotation.z=rand()*TAU;p.castShadow=false;if(i%4===0){ell('pink',x,-.13,z,.06,.05,.06,lilies);}}for(const k of [-1,1]){const copy=lilies.clone(true);copy.position.x=k*120;world.add(copy);}186
const mill=new T.Group();mill.position.set(-24,landHeight(-24,-23),path(-24)-23);world.add(mill);mesh(new T.CylinderGeometry(.58,.9,3.1,12),'house',v(0,1.55,0),null,mill);mesh(new T.ConeGeometry(.85,1.15,12),'roof',v(0,3.52,0),null,mill);box('leather',0,.43,.80,.30,.80,.04,mill);const sails=new T.Group();sails.position.set(0,2.5,.65);mill.add(sails);ell('trunk',0,0,0,.13,.13,.13,sails);for(let j=0;j<4;j++){const g=new T.Group();g.rotation.z=j*PI/2;sails.add(g);link(v(0,0,0),v(0,1.53,0),.022,'trunk',g);for(let k=0;k<5;k++)box('fence',.13,.62+k*.18,0,.33,.11,.025,g);}187
const motesG=new T.BufferGeometry(),motesP=new Float32Array(70*3);for(let i=0;i<70;i++){motesP[i*3]=range(-18,18);motesP[i*3+1]=range(.5,7);motesP[i*3+2]=range(-14,14);}motesG.setAttribute('position',new T.BufferAttribute(motesP,3));const motes=new T.Points(motesG,new T.PointsMaterial({color:0xffe5ac,size:.045,transparent:true,opacity:.60,depthWrite:false}));scene.add(motes);188
const birds=new T.Group();scene.add(birds);for(let i=0;i<7;i++){const g=new T.Group();g.position.set(-17+i*2.2,10+(i%3)*.48,-29-i);birds.add(g);const m=new T.MeshBasicMaterial({color:0x637c6d});tube([[-.34,0,0],[-.14,.095,0],[0,0,0],[.16,.095,0],[.35,0,0]],.012,m,g);}189
const orbit={yaw:.69,pitch:.32,dist:10.1,targetYaw:.69,targetPitch:.32,targetDist:10.1,vy:0,vp:0};let aim=v(0,1.25,0),pointerMoved=false;190
const viewPresets=[{yaw:.69,pitch:.32,dist:10.1,label:'随行视角'},{yaw:.97,pitch:.15,dist:6.8,label:'水獭特写'},{yaw:.35,pitch:.68,dist:19,label:'河岸远景'}];191
function setView(i=0){S.view=i;const p=viewPresets[i];Object.assign(orbit,{targetYaw:p.yaw,targetPitch:p.pitch,targetDist:p.dist,vy:0,vp:0});$('view-label').textContent=p.label;}192
const pointers=new Map();let prevPinch=0,lastPointer={x:0,y:0};193
renderer.domElement.addEventListener('pointerdown',e=>{pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});lastPointer={x:e.clientX,y:e.clientY};pointerMoved=false;orbit.vy=orbit.vp=0;renderer.domElement.setPointerCapture(e.pointerId);if(pointers.size===2){const a=[...pointers.values()];prevPinch=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);}});194
renderer.domElement.addEventListener('pointermove',e=>{if(!pointers.has(e.pointerId))return;const old=pointers.get(e.pointerId);const dx=e.clientX-old.x,dy=e.clientY-old.y;pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});if(Math.abs(dx)+Math.abs(dy)>2)pointerMoved=true;if(pointers.size===2){const a=[...pointers.values()],d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);if(prevPinch>0)orbit.targetDist=T.MathUtils.clamp(orbit.targetDist*prevPinch/d,5.4,26);prevPinch=d;}else{orbit.targetYaw-=dx*.005;orbit.targetPitch=T.MathUtils.clamp(orbit.targetPitch+dy*.003,.06,1.20);orbit.vy=-dx*.001;orbit.vp=dy*.0006;}});195
const raycaster=new T.Raycaster();196
renderer.domElement.addEventListener('pointerup',e=>{if(!pointerMoved&&pointers.size===1){raycaster.setFromCamera(new T.Vector2(e.clientX/innerWidth*2-1,-e.clientY/innerHeight*2+1),camera);if(raycaster.intersectObject(otter,true).length)greet();}pointers.delete(e.pointerId);prevPinch=0;});renderer.domElement.addEventListener('pointercancel',e=>{pointers.delete(e.pointerId);prevPinch=0;});197
renderer.domElement.addEventListener('wheel',e=>{e.preventDefault();orbit.targetDist=T.MathUtils.clamp(orbit.targetDist*Math.exp(e.deltaY*.001),5.4,26);},{passive:false});198
let toastTimer;function toast(text){$('toast').textContent=text;$('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('show'),2500);}199
let audio;function ring(){try{audio=audio||new(window.AudioContext||window.webkitAudioContext)();audio.resume();for(const [f,d,g] of [[1680,0,.14],[2210,.04,.06],[1680,.15,.09],[3360,0,.025]]){const o=audio.createOscillator(),gain=audio.createGain();o.type='sine';o.frequency.value=f;const t=audio.currentTime+d;gain.gain.setValueAtTime(0,t);gain.gain.linearRampToValueAtTime(g,t+.004);gain.gain.exponentialRampToValueAtTime(.0001,t+1.1);o.connect(gain);gain.connect(audio.destination);o.start(t);o.stop(t+1.15);}}catch(e){}}200
function greet(){S.greet=1;ring();toast('叮铃铃——下午好,河岸的朋友。');}201
function togglePlay(){S.paused=!S.paused;$('play').setAttribute('aria-label',S.paused?'继续动画':'暂停动画');$('play').setAttribute('aria-pressed',S.paused);$('play-icon').innerHTML=S.paused?'<path d="m9 5 10 7-10 7Z"/>':'<path d="M9 5v14M15 5v14"/>';}202
function toggleLight(){S.night=!S.night;document.body.classList.toggle('night',S.night);$('light-label').textContent=S.night?'暮色 · 微澜':'午后 · 晴';$('light').setAttribute('aria-label',S.night?'切换为午后光照':'切换为暮色光照');}203
$('play').onclick=togglePlay;$('view').onclick=()=>setView((S.view+1)%3);$('bell').onclick=greet;$('light').onclick=toggleLight;204
$('speed').oninput=e=>{S.speed=+e.target.value;$('speed-val').textContent=S.speed<.65?'慢慢来':S.speed<1.15?'悠然':'轻快';};$('wind').oninput=e=>{S.wind=+e.target.value;$('wind-val').textContent=S.wind<.2?'微风':S.wind<.9?'轻风':'拂风';};205
$('help-btn').onclick=()=>{const open=$('help').classList.toggle('open');$('help-btn').setAttribute('aria-expanded',open);};206
$('photo').onclick=()=>{renderer.render(scene,camera);const a=document.createElement('a');a.download='河岸邮差-'+(S.night?'暮色':'午后')+'.png';a.href=renderer.domElement.toDataURL('image/png');a.click();toast('把这一刻的河风,收进口袋。');};207
document.addEventListener('keydown',e=>{if(e.target.matches('input,button,select,textarea'))return;if(e.code==='Space'){e.preventDefault();togglePlay();}if(e.code==='KeyR')setView(0);if(e.code==='KeyB')greet();if(e.code==='KeyL')toggleLight();if(e.code==='Escape'){$('help').classList.remove('open');$('help-btn').setAttribute('aria-expanded','false');}});208
renderer.domElement.addEventListener('webglcontextlost',e=>{e.preventDefault();$('error-text').textContent='三维画面被浏览器暂时中断。请重新打开此文件继续河岸之旅。';$('error').style.display='flex';});209
function resize(){renderer.setSize(innerWidth,innerHeight);camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();}addEventListener('resize',resize);210
const dayFog=new T.Color(0xdce6d3),duskFog=new T.Color(0x8eaaa9),daySun=new T.Color(0xffe5b7),duskSun=new T.Color(0xffb483),dayHemi=new T.Color(0xe9f5e9),duskHemi=new T.Color(0x9abed1);211
let last=performance.now(),frameCount=0;let first=true;212
function update(dt){if(!S.paused){S.time+=dt;const velocity=1.95*S.speed;S.travel+=velocity*dt;S.crank+=velocity*dt/.586/2.55;}213
const t=S.time,phase=S.crank,bob=Math.sin(phase*2)*.014;S.greet=Math.max(0,S.greet-dt*.35);S.light=T.MathUtils.damp(S.light,S.night?1:0,2.1,dt);214
const progress=((S.travel+60)%120+120)%120-60,z=path(progress),heading=-Math.atan(slope(progress));215
world.position.x=-progress;hero.position.set(0,.015,z);hero.rotation.y=heading;hero.rotation.x=Math.sin(progress*TAU/120)*.01;216
for(const {g,k} of tiles)g.position.x=k*120;217
for(const d of ducks){let x=d.x;if(x-progress<-60)x+=120;if(x-progress>60)x-=120;d.g.position.x=x+Math.sin(t*.25+d.x)*.7;d.g.position.y=-.12+Math.sin(t*2+d.x)*.011;d.g.rotation.z=Math.sin(t*1.8+d.x)*.018;d.wake.rotation.y=Math.sin(t+d.x)*.02;}218
for(const w of wheels)w.rotation.z=-S.travel/.586;frontRoot.rotation.y=Math.sin(progress*TAU/120)*.025;219
for(let i=0;i<2;i++){const a=phase+(i?PI:0),foot=v(bb.x+.24*Math.cos(a),bb.y+.24*Math.sin(a),i?.27:-.27);crankArms[i].rotation.z=a;pedals[i].position.copy(foot);const l=legs[i],hip=v(-.39,1.74+bob,l.side*.235),knee=solveKnee(hip,foot);poseLink(l.upper,hip,knee,.135);poseLink(l.lower,knee,foot,.097);l.knee.position.copy(knee);l.foot.position.copy(foot).add(v(.035,.087,0));l.foot.rotation.z=Math.sin(a)*.11;}220
torso.position.y=bob;torso.rotation.z=Math.sin(phase*2)*.008;headGroup.position.y=2.69+bob*.65+Math.sin(t*1.8)*.009;headGroup.rotation.y=-S.greet*.42+Math.sin(t*.65)*.038;headGroup.rotation.z=Math.sin(t*1.4)*.018+S.greet*.07;221
const blinkPhase=t%5.7;const blink=blinkPhase>5.47?Math.max(.08,Math.abs((blinkPhase-5.585)/.115)):1;for(const e of eyes)e.scale.y=blink;222
for(const a of arms){const shoulder=v(-.06,2.32+bob,a.side*.267),elbow=v(.36,2.055+bob*.4,a.side*.31),hand=v(.88,1.91,a.side*.38);poseLink(a.upper,shoulder,elbow,.111);poseLink(a.lower,elbow,hand,.086);a.elbow.position.copy(elbow);a.paw.rotation.z=-.2;}223
parcels.position.y=1.40+Math.abs(Math.sin(phase*2+.5))*.009;parcels.rotation.x=Math.sin(phase)*.008;224
updateTail(t);updateScarf(t);animatedUniforms.time.value=t;animatedUniforms.wind.value=S.wind;waterUniforms.uTime.value=t;waterUniforms.uDusk.value=S.light;skyU.dusk.value=S.light;225
scene.fog.color.copy(dayFog).lerp(duskFog,S.light);sun.color.copy(daySun).lerp(duskSun,S.light);sun.intensity=3.25-S.light*1.95;hemi.color.copy(dayHemi).lerp(duskHemi,S.light);hemi.intensity=1.85-S.light*.55;fill.intensity=.65+S.light*.15;renderer.toneMappingExposure=1.02-S.light*.06;226
sun.position.z=8+z;sun.target.position.set(0,0,z);nightLamp.position.set(.96,1.52,z);nightLamp.intensity=S.light*2.4;227
clouds.position.x=Math.sin(t*.012)*4;birds.position.x=Math.sin(t*.11)*4;birds.position.y=Math.sin(t*.4)*.25;sails.rotation.z=-t*(.13+S.wind*.08);mill.position.x=-24+(progress>36?120:0);228
motes.rotation.y=t*.011;motes.position.z=z;for(let i=0;i<70;i++){motesP[i*3+1]+=S.paused?0:dt*.025*(1+(i%3));if(motesP[i*3+1]>7)motesP[i*3+1]=.5;}motesG.attributes.position.needsUpdate=true;229
if(!pointers.size){orbit.targetYaw+=orbit.vy*dt*60;orbit.targetPitch=T.MathUtils.clamp(orbit.targetPitch+orbit.vp*dt*60,.06,1.2);orbit.vy*=Math.pow(.91,dt*60);orbit.vp*=Math.pow(.91,dt*60);}230
orbit.yaw=T.MathUtils.damp(orbit.yaw,orbit.targetYaw,8,dt);orbit.pitch=T.MathUtils.damp(orbit.pitch,orbit.targetPitch,8,dt);orbit.dist=T.MathUtils.damp(orbit.dist,orbit.targetDist,7,dt);231
const portraitScale=innerWidth/innerHeight<.8?1.40:1;const dist=orbit.dist*portraitScale,cp=Math.cos(orbit.pitch);aim.set(0,S.view===1?1.90:1.25,z);camera.position.set(aim.x+Math.sin(orbit.yaw)*dist*cp,aim.y+Math.sin(orbit.pitch)*dist,aim.z+Math.cos(orbit.yaw)*dist*cp);camera.lookAt(aim);sky.position.copy(camera.position);232
}233
function animate(now){requestAnimationFrame(animate);const dt=document.hidden?0:Math.min(.05,(now-last)/1000);last=now;update(dt);renderer.render(scene,camera);frameCount++;if(first){first=false;setTimeout(()=>{$('loading').style.opacity='0';setTimeout(()=>$('loading').remove(),700);},350);}}234
update(.016);requestAnimationFrame(animate);235
// Small public diagnostic surface for offline validation; no external services.236
window.courier={state:S,scene,camera,renderer,hero,legs,orbit,setView,update,getStats:()=>({frames:frameCount,drawCalls:renderer.info.render.calls,triangles:renderer.info.render.triangles,three:T.REVISION})};237
})();238
</script>239
</body>240
</html>