返回报告

07 · GPT-6.1 Sol / 极高(xhigh) · Fast · 第3次

原场景源文件,未修复或改写。下载源文件 · 源码目录 · 运行原交付

1/* River Post — all geometry, textures and audio are generated locally.
2 Three.js r160.1 is embedded under its MIT license. No network requests. */
3(() => {
4'use strict';
5const T = THREE, $ = id => document.getElementById(id);
6const V = (x=0,y=0,z=0) => new T.Vector3(x,y,z);
7const clamp = T.MathUtils.clamp, TAU = Math.PI * 2;
8let seed = 48271;
9function rand(a=0,b=1){seed=(seed*16807)%2147483647;return a+(b-a)*(seed/2147483647);}
10let renderer;
11try {
12 renderer = new T.WebGLRenderer({antialias:true,alpha:false,powerPreference:'high-performance'});
13} catch(e) {
14 $('loading').innerHTML='<strong>需要开启浏览器的 3D 支持</strong><p>请在 Chrome 或 Edge 中开启硬件加速后重新打开。</p>';
15 return;
16}
17renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));
18renderer.setSize(innerWidth,innerHeight);
19renderer.shadowMap.enabled=true;
20renderer.shadowMap.type=T.PCFSoftShadowMap;
21renderer.outputColorSpace=T.SRGBColorSpace;
22renderer.toneMapping=T.ACESFilmicToneMapping;
23renderer.toneMappingExposure=1.08;
24$('scene').appendChild(renderer.domElement);
25renderer.domElement.tabIndex=0;
26const scene = new T.Scene();
27scene.background=new T.Color('#e9e7d0');
28scene.fog=new T.Fog('#e1e4ce',24,85);
29const camera=new T.PerspectiveCamera(35,innerWidth/innerHeight,.1,220);
30const cameraState={theta:.28,phi:1.39,size:8.3,target:V(0,1.6,-2.6)};
31const cameraGoal={theta:.28,phi:1.39,size:8.3,target:V(0,1.6,-2.6)};
32const hemi=new T.HemisphereLight('#fff3dd','#7f9b75',1.8);scene.add(hemi);
33const sunLight=new T.DirectionalLight('#ffe1ae',2.8);
34sunLight.position.set(-7,11,6);sunLight.target.position.set(0,0,-2);
35sunLight.castShadow=true;sunLight.shadow.mapSize.set(2048,2048);
36Object.assign(sunLight.shadow.camera,{left:-17,right:17,top:15,bottom:-15,near:.5,far:45});
37sunLight.shadow.normalBias=.035;sunLight.shadow.bias=-.00018;
38scene.add(sunLight,sunLight.target);
39const fill=new T.DirectionalLight('#d4ebe2',.65);fill.position.set(7,5,-8);scene.add(fill);
40
41const materialCache=new Map();
42function mat(c,roughness=.78,metalness=0){const key=c+':'+roughness+':'+metalness;if(!materialCache.has(key))materialCache.set(key,new T.MeshStandardMaterial({color:c,roughness,metalness}));return materialCache.get(key);}
43const M={fur:mat('#805338'),furLight:mat('#946244'),cream:mat('#e7cda3'),muzzle:mat('#f4dfb5'),nose:mat('#352820',.42),eye:mat('#201c18',.21),pink:mat('#c27c62'),teal:mat('#3d8b80',.32,.27),trim:mat('#ebcf8c',.3,.55),tire:mat('#343d39'),rim:mat('#d2cab4',.36,.7),spoke:mat('#9aada0',.38,.72),leather:mat('#7f4a34'),scarf:mat('#cc654c'),scarfLight:mat('#e6996a'),wood:mat('#997153'),woodDark:mat('#674f40'),stone:mat('#cdc5aa'),grass:mat('#8f9a6f'),bank:mat('#bdc1a0')};
44const sphereGeo=new T.SphereGeometry(1,28,20), smallSphereGeo=new T.SphereGeometry(1,14,10), boxGeo=new T.BoxGeometry(1,1,1), cylGeo=new T.CylinderGeometry(1,1,1,12);
45function mesh(parent,geo,material,x=0,y=0,z=0,sx=1,sy=1,sz=1,shadow=true){const o=new T.Mesh(geo,material);o.position.set(x,y,z);o.scale.set(sx,sy,sz);o.castShadow=shadow;o.receiveShadow=shadow;parent.add(o);return o;}
46function ell(parent,m,p,s,small=false,shadow=true){return mesh(parent,small?smallSphereGeo:sphereGeo,m,...p,...s,shadow);}
47function box(parent,m,p,s,shadow=true){return mesh(parent,boxGeo,m,...p,...s,shadow);}
48function rod(parent,a,b,r,m,shadow=true){a=Array.isArray(a)?V(...a):a;b=Array.isArray(b)?V(...b):b;const o=mesh(parent,cylGeo,m,0,0,0,r,a.distanceTo(b),r,shadow);o.position.copy(a).add(b).multiplyScalar(.5);o.quaternion.setFromUnitVectors(V(0,1,0),b.clone().sub(a).normalize());return o;}
49function tube(parent,points,r,m,segments=32,shadow=true){const curve=new T.CatmullRomCurve3(points.map(p=>Array.isArray(p)?V(...p):p));return mesh(parent,new T.TubeGeometry(curve,segments,r,8,false),m,0,0,0,1,1,1,shadow);}
50const roundCache=new Map();
51function roundGeo(w,h,d,r=.06){const key=[w,h,d,r].join(',');if(roundCache.has(key))return roundCache.get(key);r=Math.min(r,w/2,h/2,d/2);const s=new T.Shape();const x=-w/2,y=-h/2;
52 s.moveTo(x+r,y);s.lineTo(x+w-r,y);s.quadraticCurveTo(x+w,y,x+w,y+r);s.lineTo(x+w,y+h-r);s.quadraticCurveTo(x+w,y+h,x+w-r,y+h);s.lineTo(x+r,y+h);s.quadraticCurveTo(x,y+h,x,y+h-r);s.lineTo(x,y+r);s.quadraticCurveTo(x,y,x+r,y);
53 const g=new T.ExtrudeGeometry(s,{depth:d-2*r,bevelEnabled:true,bevelThickness:r,bevelSize:r*.55,bevelSegments:3,steps:1,curveSegments:5});g.translate(0,0,-d/2+r);roundCache.set(key,g);return g;
54}
55function rounded(parent,m,p,s,r=.06){return mesh(parent,roundGeo(...s,r),m,...p);}
56const moving=[];
57function drift(group,x,span=96){group.position.x=x;moving.push({group,x,span});return group;}
58function canvasTexture(w,h,draw){const c=document.createElement('canvas');c.width=w;c.height=h;draw(c.getContext('2d'),w,h);const tex=new T.CanvasTexture(c);tex.colorSpace=T.SRGBColorSpace;tex.anisotropy=Math.min(4,renderer.capabilities.getMaxAnisotropy());return tex;}
59
60// An atmospheric backdrop, without external images.
61const sky=new T.Mesh(new T.SphereGeometry(140,24,16),new T.ShaderMaterial({side:T.BackSide,depthWrite:false,uniforms:{top:{value:new T.Color('#b8d2c5')},bottom:{value:new T.Color('#f6e8c9')}},vertexShader:'varying float vY; void main(){vY=position.y;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',fragmentShader:'uniform vec3 top;uniform vec3 bottom;varying float vY;void main(){float h=smoothstep(-10.,72.,vY);gl_FragColor=vec4(mix(bottom,top,h),1.);#include <tonemapping_fragment>\n#include <colorspace_fragment>\n}'}));
62// Shader include directives must start on their own line.
63sky.material.fragmentShader=sky.material.fragmentShader.replace(';#include',';\n#include');scene.add(sky);
64const sun=mesh(scene,new T.SphereGeometry(3.4,32,16),new T.MeshBasicMaterial({color:'#fff0bc'}),0,8,-54,1,1,.2,false);
65const cloudMat=mat('#f7eedb');
66for(let k=0;k<8;k++){const g=new T.Group();g.position.set(rand(-45,45),rand(9,16),rand(-55,-35));for(let j=0;j<4;j++)ell(g,cloudMat,[j*1.35,Math.sin(j)*.3,0],[1.9,rand(.4,.75),.7],true,false);scene.add(g);}
67const hillColors=['#b3bea1','#a8b79a','#9eae90'];
68for(let i=0;i<14;i++)ell(scene,mat(hillColors[i%3]),[-65+i*10,-1.4,-40-rand(0,8)],[rand(8,14),rand(3,6),rand(5,8)],false,false);
69
70// River, towpath, meadow and the continuous stone edge.
71box(scene,mat('#9fa782'),[0,-.37,4.6],[240,.55,13]);
72box(scene,mat('#b1b58b'),[0,-.24,-1.65],[240,.3,1.2]);
73box(scene,mat('#a3ad88'),[0,-.29,-19],[240,.42,13.2]);
74const pathTexture=canvasTexture(512,256,(c,w,h)=>{c.fillStyle='#d4c5a5';c.fillRect(0,0,w,h);for(let i=0;i<4800;i++){const v=rand(0,1);c.fillStyle=v>.5?'rgba(114,98,76,.075)':'rgba(255,251,225,.17)';c.fillRect(rand(0,w),rand(0,h),rand(1,3),rand(1,3));} });
75pathTexture.wrapS=pathTexture.wrapT=T.RepeatWrapping;pathTexture.repeat.set(55,1);
76const pathMat=new T.MeshStandardMaterial({map:pathTexture,roughness:1});
77box(scene,pathMat,[0,-.055,0],[240,.1,3.1]);
78const riverUniforms={time:{value:0},shift:{value:0},light:{value:new T.Color('#afc9ad')},deep:{value:new T.Color('#578f8a')}};
79const water=new T.Mesh(new T.PlaneGeometry(240,10.4,90,15),new T.ShaderMaterial({uniforms:riverUniforms,vertexShader:`uniform float time; varying vec3 vP; void main(){vec3 p=position;p.z+=sin(p.x*.55+time*.8)*.015+cos(p.y*1.5+time*.6)*.016;vP=p;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);}`,fragmentShader:`uniform float time;uniform float shift;uniform vec3 light;uniform vec3 deep;varying vec3 vP;
80 float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);}
81 void main(){vec2 p=vP.xy;p.x+=shift*.2;float waves=sin(p.y*7.+sin(p.x*.75+time*.7)*.65+time*.9);float fine=sin(p.y*23.+p.x*2.+time*1.7);float mask=smoothstep(-.1,.8,sin(p.x*.8+p.y*.9)*cos(p.x*.25-p.y*.4));float stripe=smoothstep(.96,1.,waves)*mask*.44;float caustic=smoothstep(.96,1.,fine)*mask*.06;float n=hash(floor(p*9.))* .02;vec3 c=mix(deep,light,.31+.065*sin(p.y*.9+p.x*.12)+stripe+caustic+n);gl_FragColor=vec4(c,1.);
82 #include <tonemapping_fragment>
83 #include <colorspace_fragment>
84 }`}));
85water.rotation.x=-Math.PI/2;water.position.set(0,-.245,-7.4);scene.add(water);
86const edge=new T.Group();
87for(let i=0;i<480;i++){const b=box(edge,M.stone,[-120+i*.5,-.08,-2.05],[.48,.34,.34],false);b.rotation.y=rand(-.045,.045);}
88drift(edge,0,80);scene.add(edge);
89const farEdge=new T.Group();
90for(let i=0;i<330;i++)box(farEdge,M.bank,[-132+i*.8,-.18,-12.73],[.78,.29,.5],false);
91drift(farEdge,0,88);scene.add(farEdge);
92
93// A real bicycle: two wheels, frame triangles, crank, chain, fenders, rack.
94const bike=new T.Group();bike.position.set(0,.028,0);bike.scale.setScalar(1.23);scene.add(bike);
95const wheelRadius=.55, rearX=-.9, frontX=.94, axleY=.565;
96const wheels=[];
97function makeWheel(x){const root=new T.Group();root.position.set(x,axleY,0);bike.add(root);
98 const tire=mesh(root,new T.TorusGeometry(wheelRadius,.043,12,64),M.tire);tire.receiveShadow=true;
99 mesh(root,new T.TorusGeometry(wheelRadius-.033,.009,6,64),M.rim);
100 mesh(root,new T.TorusGeometry(wheelRadius-.016,.004,4,64),mat('#dac99b'));
101 for(let i=0;i<20;i++){const a=i/20*TAU;rod(root,[0,0,(i%2?.021:-.021)],[Math.cos(a)*.509,Math.sin(a)*.509,0],.0045,M.spoke,false);}
102 const hub=rod(root,[0,0,-.09],[0,0,.09],.032,M.rim);
103 const reflector=rounded(root,mat('#eccb71'),[.35,0,.012],[.082,.025,.026],.008);
104 wheels.push(root);return root;
105}
106makeWheel(rearX);makeWheel(frontX);
107const rear=V(rearX,axleY,0),front=V(frontX,axleY,0),crank=V(-.1,.66,0),seatStem=V(-.42,1.26,0),headBottom=V(.65,1.10,0),headTop=V(.58,1.37,0);
108for(const [a,b] of [[rear,crank],[crank,seatStem],[seatStem,rear],[seatStem,headTop],[headBottom,crank],[headBottom,headTop]])rod(bike,a,b,.028,M.teal);
109for(const z of [-.062,.062]){rod(bike,[rearX,axleY,z],[-.42,1.26,z],.019,M.teal);rod(bike,[.65,1.1,z],[frontX,axleY,z],.022,M.teal);}
110rod(bike,[-.42,1.24,0],[-.48,1.4,0],.02,M.rim);
111rounded(bike,M.leather,[-.46,1.415,0],[.4,.082,.25],.035);
112rod(bike,[.58,1.37,0],[.55,1.53,0],.022,M.rim);
113tube(bike,[[.55,1.51,-.29],[.67,1.54,-.22],[.55,1.53,0],[.67,1.54,.22],[.55,1.51,.29]],.019,M.rim,24);
114for(const z of [-.29,.29]){rod(bike,[.55,1.515,z],[.75,1.515,z],.03,M.leather);}
115const bellDome=ell(bike,M.trim,[.58,1.58,.18],[.046,.038,.046]);
116rod(bike,[.76,1.20,0],[.9,1.2,0],.028,M.rim);
117ell(bike,M.trim,[.91,1.20,0],[.075,.083,.068]);
118ell(bike,mat('#fff0c6'),[.966,1.20,0],[.025,.052,.052]);
119for(const x of [rearX,frontX]){const pts=[];for(let i=0;i<=28;i++){const a=.16+(Math.PI-.32)*i/28;pts.push([x+Math.cos(a)*.619,axleY+Math.sin(a)*.619,0]);}tube(bike,pts,.028,M.teal,30);for(const z of [-.07,.07])rod(bike,[x,axleY,z],[x-.37,axleY+.48,z],.008,M.rim);}
120// Cargo rack is behind the saddle, leaving the legs free.
121rounded(bike,M.wood,[-1.07,1.27,0],[.68,.065,.42],.02);
122for(const z of [-.18,.18]){rod(bike,[-.9,.57,z],[-1.34,1.24,z],.012,M.teal);rod(bike,[-.9,.57,z],[-.77,1.24,z],.012,M.teal);}
123ell(bike,mat('#ba513a'),[-1.43,1.22,0],[.025,.041,.047]);
124const chainPoints=[];for(let i=0;i<=20;i++){const a=-Math.PI/2+Math.PI*i/20;chainPoints.push([-.1+Math.cos(a)*.125,.66+Math.sin(a)*.125,.085]);}for(let i=0;i<=20;i++){const a=Math.PI/2+Math.PI*i/20;chainPoints.push([-.9+Math.cos(a)*.065,.565+Math.sin(a)*.065,.085]);}chainPoints.push(chainPoints[0]);tube(bike,chainPoints,.006,mat('#767c67',.45,.5),42,false);
125const chainRing=mesh(bike,new T.TorusGeometry(.118,.012,8,32),M.trim,-.1,.66,.095);
126const crankGroup=new T.Group();crankGroup.position.copy(crank);bike.add(crankGroup);
127for(const side of [-1,1])rod(crankGroup,[0,0,side*.15],[side*.205,0,side*.15],.016,M.rim);
128const pedals=[];
129for(const side of [-1,1])pedals.push(rounded(bike,M.woodDark,[0,0,side*.215],[.17,.035,.16],.01));
130
131// Hand-wrapped parcels and a stamped address label.
132const cargo=new T.Group();cargo.position.set(-1.08,1.30,0);bike.add(cargo);
133const parcelMat=mat('#d9aa6c');
134rounded(cargo,parcelMat,[0,.205,0],[.6,.40,.39],.024);
135rounded(cargo,mat('#e6bf82'),[.035,.485,-.025],[.44,.16,.31],.018);
136box(cargo,mat('#b97749'),[0,.205,0],[.027,.414,.406]);
137box(cargo,mat('#b97749'),[0,.205,0],[.61,.414,.019]);
138box(cargo,mat('#c09661'),[.035,.485,-.025],[.028,.174,.321]);
139const labelTex=canvasTexture(256,160,(c,w,h)=>{c.fillStyle='#f5e6bd';c.fillRect(0,0,w,h);c.strokeStyle='#756b4f';c.lineWidth=2;c.strokeRect(7,7,w-14,h-14);c.fillStyle='#685b41';c.font='17px Georgia';c.fillText('TO: A DEAR FRIEND',19,40);c.fillRect(20,67,125,2);c.fillRect(20,84,94,2);c.fillRect(20,101,113,2);c.strokeStyle='#ab6650';c.beginPath();c.arc(201,96,27,0,TAU);c.stroke();c.font='13px Georgia';c.fillStyle='#ab6650';c.fillText('POST',184,101);});
140const label=new T.Mesh(new T.PlaneGeometry(.25,.145),new T.MeshStandardMaterial({map:labelTex,roughness:.9}));label.position.set(-.09,.24,.209);cargo.add(label);
141// A loop and knot on top.
142tube(cargo,[[-.13,.572,-.045],[-.14,.61,-.10],[0,.59,-.025],[.15,.617,.055],[.15,.575,.06],[0,.584,-.025]],.009,mat('#91764e'),28);
143ell(cargo,mat('#91764e'),[.02,.588,-.025],[.027,.02,.02],true);
144
145// The otter: soft silhouette, small round ears, full muzzle and tapered tail.
146const rider=new T.Group();bike.add(rider);
147const torso=ell(rider,M.fur,[-.35,1.73,0],[.365,.49,.30]);torso.rotation.z=-.23;
148const tummy=ell(rider,M.cream,[-.12,1.71,.005],[.16,.34,.254]);tummy.rotation.z=-.3;
149ell(rider,M.fur,[-.46,1.41,0],[.3,.24,.32]);
150const head=new T.Group();head.position.set(-.04,2.28,0);rider.add(head);
151ell(head,M.furLight,[0,0,0],[.43,.397,.345]);
152ell(head,M.cream,[.225,-.15,0],[.276,.239,.283]);
153for(const s of [-1,1]){
154 const ear=ell(head,M.fur,[-.16,.255,s*.266],[.104,.103,.063]);ear.rotation.x=s*.25;
155 ell(head,M.pink,[-.151,.267,s*.32],[.055,.056,.012]);
156 ell(head,M.muzzle,[.358,-.106,s*.083],[.158,.117,.127]);
157}
158const nose=ell(head,M.nose,[.482,-.049,0],[.076,.052,.075]);nose.rotation.z=.12;
159ell(head,mat('#7d6250'),[.495,-.022,.026],[.015,.008,.015],true,false);
160const eyes=[];
161for(const s of [-1,1]){
162 const eyeRoot=new T.Group();eyeRoot.position.set(.248,.080,s*.288);head.add(eyeRoot);
163 ell(eyeRoot,M.eye,[0,0,0],[.034,.051,.029]);
164 ell(eyeRoot,mat('#fff9df'),[.010,.022,s*.025],[.010,.012,.007],true,false);
165 eyes.push(eyeRoot);
166 tube(head,[[.182,.174,s*.257],[.22,.185,s*.260],[.253,.176,s*.263]],.009,M.fur,12,false);
167 ell(head,mat('#c28768'),[.254,-.138,s*.25],[.055,.026,.013],true,false);
168 for(let j=0;j<3;j++){const y=-.101-j*.042;tube(head,[[.38,y,s*.10],[.32,y+.005,s*.31],[.20-j*.032,y+.025-j*.025,s*.43]],.0035,mat('#5a4436'),8,false);}
169}
170tube(head,[[.46,-.104,0],[.415,-.191,0],[.345,-.198,0]],.007,M.nose,12,false);
171// The tail is built as a continuous tapered mesh and flexed gently every frame.
172const tailSeg=24, tailSides=14, tailPos=[],tailIndices=[];
173for(let i=0;i<=tailSeg;i++)for(let j=0;j<=tailSides;j++)tailPos.push(0,0,0);
174for(let i=0;i<tailSeg;i++)for(let j=0;j<tailSides;j++){const a=i*(tailSides+1)+j,b=a+tailSides+1;tailIndices.push(a,b,a+1,b,b+1,a+1);}
175const tailGeo=new T.BufferGeometry();tailGeo.setAttribute('position',new T.Float32BufferAttribute(tailPos,3));tailGeo.setIndex(tailIndices);
176const tail=mesh(rider,tailGeo,M.fur);tail.frustumCulled=false;
177function updateTail(time){const arr=tailGeo.attributes.position.array;let n=0;for(let i=0;i<=tailSeg;i++){const t=i/tailSeg,rr=.205*Math.pow(1-t,.9)+.010;const cx=-.51-t*1.12,cy=1.43-.78*Math.sin(t*Math.PI*.53),cz=-.08+Math.sin(time*1.8-t*3)*.05*t;for(let j=0;j<=tailSides;j++){const a=j/tailSides*TAU;arr[n++]=cx;arr[n++]=cy+Math.cos(a)*rr*.77;arr[n++]=cz+Math.sin(a)*rr;}}tailGeo.attributes.position.needsUpdate=true;tailGeo.computeVertexNormals();}
178
179// Two-bone leg IK keeps each ankle fixed to its moving pedal.
180const legParts=[];
181for(const side of [-1,1]){
182 const upper=ell(rider,M.fur,[0,0,0],[.15,.3,.13]);
183 const lower=ell(rider,M.fur,[0,0,0],[.115,.25,.11]);
184 const knee=ell(rider,M.fur,[0,0,0],[.145,.145,.135]);
185 const foot=ell(rider,M.fur,[0,0,0],[.183,.085,.115]);
186 const claws=[];for(let j=0;j<3;j++)claws.push(ell(rider,mat('#cfb59a'),[0,0,0],[.022,.011,.010],true,false));
187 legParts.push({side,upper,lower,knee,foot,claws});
188}
189function limbBetween(o,a,b,r){o.position.copy(a).add(b).multiplyScalar(.5);o.scale.set(r,a.distanceTo(b)*.58,r*.92);o.quaternion.setFromUnitVectors(V(0,1,0),b.clone().sub(a).normalize());}
190const arms=[];
191for(const side of [-1,1]){
192 const shoulder=V(-.13,1.99,side*.255),elbow=V(.19,1.67,side*.30),wrist=V(.62,1.54,side*.295);
193 const upper=ell(rider,M.fur,[0,0,0],[1,1,1]);limbBetween(upper,shoulder,elbow,.13);
194 const lower=ell(rider,M.fur,[0,0,0],[1,1,1]);limbBetween(lower,elbow,wrist,.092);
195 ell(rider,M.fur,[elbow.x,elbow.y,elbow.z],[.108,.105,.108]);
196 const paw=ell(rider,M.fur,[wrist.x+.024,wrist.y-.005,wrist.z],[.122,.078,.075]);
197 for(let j=0;j<3;j++)ell(rider,M.furLight,[wrist.x+.070,wrist.y-.054,wrist.z-.041+j*.039],[.041,.023,.012],true);
198 arms.push({upper,lower,paw,side});
199}
200
201// Knitted neck wrap and a moving ribbon, with curved width and striped hems.
202const scarfWrap=ell(rider,M.scarf,[-.055,2.035,0],[.31,.092,.305]);scarfWrap.rotation.z=-.14;
203const scarfKnot=ell(rider,M.scarf,[-.23,2.053,.27],[.125,.116,.105]);
204const scarfSeg=40, scarfCols=6, scarfPos=[],scarfUV=[],scarfIndex=[];
205for(let i=0;i<=scarfSeg;i++)for(let j=0;j<=scarfCols;j++){scarfPos.push(0,0,0);scarfUV.push(i/scarfSeg,j/scarfCols);}
206for(let i=0;i<scarfSeg;i++)for(let j=0;j<scarfCols;j++){const a=i*(scarfCols+1)+j,b=a+scarfCols+1;scarfIndex.push(a,b,a+1,b,b+1,a+1);}
207const scarfTex=canvasTexture(512,128,(c,w,h)=>{c.fillStyle='#c75c45';c.fillRect(0,0,w,h);c.fillStyle='#eaa176';for(const x of [w*.71,w*.77,w*.85,w*.91])c.fillRect(x,0,9,h);for(let x=0;x<w;x+=4){c.fillStyle=x%8?'rgba(255,230,183,.08)':'rgba(94,35,24,.07)';c.fillRect(x,0,1,h);}for(let y=0;y<h;y+=4){c.fillStyle='rgba(246,197,150,.06)';c.fillRect(0,y,w,1);}c.fillStyle='#efa97b';c.fillRect(0,0,w,4);c.fillRect(0,h-4,w,4);});
208const scarfGeo=new T.BufferGeometry();scarfGeo.setAttribute('position',new T.Float32BufferAttribute(scarfPos,3));scarfGeo.setAttribute('uv',new T.Float32BufferAttribute(scarfUV,2));scarfGeo.setIndex(scarfIndex);
209const scarf=mesh(rider,scarfGeo,new T.MeshStandardMaterial({map:scarfTex,roughness:.95,side:T.DoubleSide}));scarf.frustumCulled=false;
210const fringe=[];for(let j=0;j<7;j++)fringe.push(rod(rider,[0,0,0],[.01,.01,.01],.008,M.scarfLight,false));
211function scarfPoint(t,q,time,speed){const gust=Math.sin(time*2.5-t*8)*t*.11+Math.sin(time*4.1-t*13)*t*.035;const lift=.07+speed*.085,roll=.7+Math.sin(time*2.2-t*5)*.5*t,width=.25-.035*t;return V(-.25-t*1.40,2.06+Math.sin(t*Math.PI*.78)*lift+gust-t*t*.10+q*width*Math.sin(roll),.28+Math.sin(time*2-t*6)*.067*t+q*width*Math.cos(roll));}
212function updateScarf(time,speed){const arr=scarfGeo.attributes.position.array;let n=0;for(let i=0;i<=scarfSeg;i++)for(let j=0;j<=scarfCols;j++){const p=scarfPoint(i/scarfSeg,j/scarfCols-.5,time,speed);arr[n++]=p.x;arr[n++]=p.y;arr[n++]=p.z;}scarfGeo.attributes.position.needsUpdate=true;scarfGeo.computeVertexNormals();for(let j=0;j<fringe.length;j++){const a=scarfPoint(1,j/(fringe.length-1)-.5,time,speed),b=a.clone().add(V(-.082,-.027+Math.sin(time*5+j)*.01,.008*Math.sin(j+time)));const o=fringe[j];o.position.copy(a).add(b).multiplyScalar(.5);o.scale.set(.008,a.distanceTo(b),.008);o.quaternion.setFromUnitVectors(V(0,1,0),b.clone().sub(a).normalize());}}
213
214// Small stitched mail bag strapped beside the rack.
215const mailbag=new T.Group();mailbag.position.set(-1.10,1.06,-.26);bike.add(mailbag);
216rounded(mailbag,mat('#759381'),[0,0,0],[.4,.44,.17],.065);
217rounded(mailbag,mat('#89a28a'),[.005,.135,.006],[.4,.20,.185],.035);
218box(mailbag,M.trim,[0,.1,.10],[.042,.072,.014]);
219tube(bike,[[-.96,1.09,-.36],[-.95,1.53,-.3],[-1.20,1.53,-.3],[-1.26,1.06,-.36]],.012,M.leather,22);
220
221// Procedural plants and trees; deterministic variations keep the world coherent.
222const plantGroups=[];
223const canopyMats=['#819371','#91a06e','#b1aa67','#c5ad72','#cc9874'].map(c=>mat(c));
224function tree(x,z,size=1,kind=0){const g=new T.Group();g.position.set(0,-.085,z);g.scale.setScalar(size);
225 const h=kind===1?2.2:2.65;
226 rod(g,[0,0,0],[.035,h,0],.10,M.woodDark);
227 for(let i=0;i<4;i++){const a=i*2.4;rod(g,[.01,h*.49,0],[Math.sin(a)*.65,h*.8,Math.cos(a)*.48],.04,M.wood);}
228 const crown=new T.Group();crown.position.y=h*.86;g.add(crown);
229 for(let j=0;j<8;j++){const a=j*2.4,r=j===0?0:rand(.25,.66);ell(crown,canopyMats[(kind+j%2)%canopyMats.length],[Math.cos(a)*r,rand(-.04,.68),Math.sin(a)*r],[rand(.54,.85),rand(.65,.94),rand(.5,.73)],true);}
230 if(kind===0){for(let j=0;j<6;j++){const a=j/6*TAU;const pts=[];for(let i=0;i<5;i++)pts.push([Math.cos(a)*(.42+i*.10),h+.22-i*.26,Math.sin(a)*(.42+i*.10)]);tube(g,pts,.047,canopyMats[0],12);}}
231 plantGroups.push({crown,phase:rand(0,TAU)});scene.add(g);drift(g,x,96);return g;
232}
233for(let i=0;i<12;i++)tree(-46+i*8,-1.88-rand(0,.07),rand(.65,1.05),i%4);
234for(let i=0;i<14;i++)tree(-49+i*7.1,-15.8-rand(0,4),rand(.7,1.25),i%5);
235
236// Instanced blades, clover flowers and gravel in repeating meadow tiles.
237const bladeShape=new T.Shape();bladeShape.moveTo(-.026,0);bladeShape.quadraticCurveTo(-.01,.23,.03,.32);bladeShape.quadraticCurveTo(.055,.17,.026,0);bladeShape.closePath();
238const bladeGeo=new T.ShapeGeometry(bladeShape);const bladeMat=new T.MeshStandardMaterial({color:'#7e9465',roughness:1,side:T.DoubleSide});
239const dummy=new T.Object3D();
240for(let tile=0;tile<5;tile++){
241 const g=new T.Group();const count=370,blades=new T.InstancedMesh(bladeGeo,bladeMat,count);blades.receiveShadow=true;
242 for(let i=0;i<count;i++){dummy.position.set(rand(-12,12),-.075,rand(1.65,6.8));dummy.rotation.set(0,rand(0,TAU),rand(-.25,.25));dummy.scale.setScalar(rand(.45,1.2));dummy.updateMatrix();blades.setMatrixAt(i,dummy.matrix);}
243 g.add(blades);
244 const stones=new T.InstancedMesh(smallSphereGeo,mat('#b7b49a'),90);
245 for(let i=0;i<90;i++){dummy.position.set(rand(-12,12),-.006,rand(-1.45,1.45));dummy.rotation.set(0,rand(0,TAU),0);dummy.scale.set(rand(.011,.032),rand(.003,.015),rand(.015,.05));dummy.updateMatrix();stones.setMatrixAt(i,dummy.matrix);}g.add(stones);
246 const flowerColors=['#e8ce92','#eed6b3','#c38667'];
247 for(let c=0;c<3;c++){const flowers=new T.InstancedMesh(smallSphereGeo,mat(flowerColors[c]),50);for(let i=0;i<50;i++){dummy.position.set(rand(-12,12),rand(.08,.22),rand(1.95,5.5));dummy.rotation.set(0,0,0);dummy.scale.set(.026,.028,.026);dummy.updateMatrix();flowers.setMatrixAt(i,dummy.matrix);}g.add(flowers);}
248 scene.add(g);drift(g,(tile-2)*24,120);
249}
250// Riverside reeds, moss stones, lily pads and their flowers.
251const reedMat=mat('#789374'),reedTip=mat('#8b6950');
252for(let i=0;i<23;i++){
253 const g=new T.Group();g.position.z=i%2?-12.3:-2.5;
254 for(let j=0;j<5;j++){const x=rand(-.25,.25),z=rand(-.15,.15),h=rand(.25,.68);rod(g,[x,-.27,z],[x+.06,h-.27,z+.025],.012,reedMat,false);ell(g,reedTip,[x+.06,h-.21,z+.025],[.021,.08,.022],true,false);}
255 scene.add(g);drift(g,-45+i*4.1,96);
256}
257const lilyMat=mat('#739681');
258for(let i=0;i<32;i++){const g=new T.Group();g.position.set(0,-.215,i%2?rand(-11.3,-12):rand(-3,-3.8));const pad=mesh(g,new T.CircleGeometry(rand(.10,.25),20,0,TAU-.35),lilyMat);pad.rotation.x=-Math.PI/2;pad.rotation.z=rand(0,TAU);if(i%4===0){for(let j=0;j<5;j++){const a=j/5*TAU;const petal=ell(g,mat('#e9c5a0'),[Math.sin(a)*.04,.035,Math.cos(a)*.04],[.025,.06,.026],true,false);petal.rotation.x=Math.cos(a)*.6;petal.rotation.z=Math.sin(a)*.6;}ell(g,mat('#d8b76c'),[0,.05,0],[.02,.017,.02],true,false);}scene.add(g);drift(g,rand(-47,47),96);}
259
260// Timber railing, benches and wayfinding boards along the towpath.
261function fence(x,z=-1.72){const g=new T.Group();g.position.z=z;
262 for(let j=0;j<5;j++){const xx=j*.74-1.48;rounded(g,M.wood,[xx,.28,0],[.068,.6,.075],.02);ell(g,M.wood,[xx,.59,0],[.05,.037,.05],true);}
263 for(const y of [.28,.49])rod(g,[-1.5,y,0],[1.5,y,0],.027,M.wood,false);
264 scene.add(g);drift(g,x,96);}
265for(const x of [-35,-22,-8,5,20,34,46])fence(x);
266function bench(x){const g=new T.Group();g.position.z=2.65;
267 for(let j=0;j<3;j++)rounded(g,M.wood,[0,.45,j*.12-.12],[1.25,.06,.09],.012);
268 for(const y of [.74,.90])rounded(g,M.wood,[0,y,-.22],[1.25,.11,.05],.012);
269 for(const xx of [-.47,.47]){rod(g,[xx,0,-.20],[xx,.95,-.22],.025,M.woodDark);rod(g,[xx,0,.16],[xx,.43,.16],.03,M.woodDark);}
270 scene.add(g);drift(g,x,96);}
271bench(-13);bench(28);
272const signTexture=canvasTexture(512,256,(c,w,h)=>{c.fillStyle='#e4d6ad';c.fillRect(0,0,w,h);c.strokeStyle='#789079';c.lineWidth=6;c.strokeRect(10,10,w-20,h-20);c.fillStyle='#3e635a';c.font='bold 42px Georgia';c.textAlign='center';c.fillText('RIVER POST',w/2,91);c.font='23px Georgia';c.fillText('NEXT DELIVERY →',w/2,150);c.font='20px Georgia';c.fillText('Take the scenic route',w/2,202);});
273for(const x of [-27,23]){const g=new T.Group();g.position.z=-1.5;rod(g,[0,0,0],[0,1.28,0],.043,M.woodDark);rounded(g,M.wood,[0,1.13,0],[.78,.4,.062],.025);const face=new T.Mesh(new T.PlaneGeometry(.72,.36),new T.MeshStandardMaterial({map:signTexture}));face.position.set(0,1.13,.036);g.add(face);scene.add(g);drift(g,x,96);}
274
275// A gently arched footbridge spans the entire river.
276const bridge=new T.Group();scene.add(bridge);drift(bridge,5.6,96);
277function bridgeY(t){return .03+Math.sin(t*Math.PI)*1.16;}
278const bridgeN=38;
279for(let i=0;i<bridgeN;i++){const t=(i+.5)/bridgeN,z=-2.28-t*10.55,y=bridgeY(t);const plank=box(bridge,M.wood,[0,y,z],[1.40,.095,10.55/bridgeN*.94]);plank.rotation.x=-Math.atan(Math.cos(t*Math.PI)*1.16*Math.PI/10.55);}
280for(const x of [-.64,.64]){
281 const rail=[];for(let j=0;j<=48;j++){const t=j/48;rail.push([x,bridgeY(t)+.68,-2.28-t*10.55]);}tube(bridge,rail,.032,M.woodDark,48);
282 const lowRail=rail.map(p=>[p[0],p[1]-.39,p[2]]);tube(bridge,lowRail,.022,M.woodDark,48);
283 for(let j=0;j<=12;j++){const t=j/12,z=-2.28-t*10.55,y=bridgeY(t);rod(bridge,[x,y,z],[x,y+.69,z],.029,M.woodDark);}
284 const beam=[];for(let j=0;j<=48;j++){const t=j/48;beam.push([x,bridgeY(t)-.15,-2.28-t*10.55]);}tube(bridge,beam,.085,M.woodDark,48);
285}
286for(const z of [-3.7,-6.5,-9.6,-12.2]){for(const x of [-.55,.55])rod(bridge,[x,-.45,z],[x,bridgeY((-2.28-z)/10.55)-.14,z],.078,M.woodDark);}
287
288// Far bank cottages, awnings and a tiny mail depot.
289function house(x,z,w=2.1,h=1.7,c='#e2c8a0',roofColor='#b97758'){
290 const g=new T.Group();g.position.z=z;
291 rounded(g,mat(c),[0,h/2,0],[w,h,1.75],.06);
292 const roofShape=new T.Shape();roofShape.moveTo(-w/2-.2,0);roofShape.lineTo(0,.8);roofShape.lineTo(w/2+.2,0);roofShape.closePath();
293 const roofGeo=new T.ExtrudeGeometry(roofShape,{depth:2.04,bevelEnabled:false});roofGeo.translate(0,h-.04,-1.02);mesh(g,roofGeo,mat(roofColor));
294 for(let j=0;j<8;j++){const xx=-w/2+j*w/7;const yy=h+.8-Math.abs(xx)/(w/2+.2)*.8;tube(g,[[xx,yy+.015,-1.03],[xx,yy+.015,1.03]],.014,mat('#c08a67'),4,false);}
295 rounded(g,M.woodDark,[0,.45,.9],[.42,.89,.065],.025);rounded(g,mat('#698e82'),[0,.49,.946],[.32,.73,.03],.02);ell(g,M.trim,[.10,.45,.969],[.021,.022,.012],true);
296 for(const xx of [-w*.29,w*.29]){rounded(g,M.woodDark,[xx,h*.60,.9],[.48,.58,.056],.012);box(g,mat('#7faca7',.28),[xx,h*.60,.94],[.38,.48,.015]);box(g,mat('#ece1c1'),[xx,h*.60,.955],[.027,.50,.016]);box(g,mat('#ece1c1'),[xx,h*.60,.955],[.4,.027,.016]);for(const side of [-1,1])rounded(g,mat('#78977f'),[xx+side*.27,h*.60,.93],[.12,.61,.045],.012);rounded(g,M.wood,[xx,h*.60-.36,1.0],[.57,.13,.20],.025);for(let k=0;k<6;k++)ell(g,mat(k%2?'#dfae88':'#809c73'),[xx-.21+k*.083,h*.60-.27,1.01],[.055,.08,.045],true);}
297 rounded(g,mat('#e9d9b5'),[0,.04,1.03],[.62,.12,.39],.02);
298 rounded(g,mat(roofColor),[w*.25,h+.62,-.20],[.26,.77,.29],.015);box(g,M.stone,[w*.25,h+1.03,-.20],[.32,.08,.35]);
299 scene.add(g);drift(g,x,96);return g;
300}
301house(-17,-16,2.05,1.7,'#dfc59b','#af7258');house(-14.3,-16.4,2.1,2,'#e7d6b3','#83917b');house(1,-16.4,2.8,2.2,'#d7c19b','#ab7557');house(22,-15.5,2.2,1.8,'#e7cba7','#b8765c');house(25,-16.3,2,2.1,'#d3d0aa','#7c9384');house(40,-16.1,2.4,1.9,'#e1c798','#aa7156');
302
303// A little jetty and a moored boat.
304const dock=new T.Group();dock.position.z=-12.9;scene.add(dock);drift(dock,-6,96);
305for(let i=0;i<12;i++)box(dock,M.wood,[0,-.10,.13+i*.18],[1.15,.09,.168]);
306for(const xx of [-.52,.52])for(const z of [.22,1.96])rod(dock,[xx,-.7,z],[xx,.12,z],.055,M.woodDark);
307const boat=new T.Group();boat.position.set(0,-.2,-10.1);scene.add(boat);drift(boat,-6.8,96);
308const boatShape=new T.Shape();boatShape.moveTo(-1.0,0);boatShape.quadraticCurveTo(-.8,-.46,0,-.51);boatShape.quadraticCurveTo(.8,-.46,1.15,0);boatShape.quadraticCurveTo(.8,.46,0,.51);boatShape.quadraticCurveTo(-.8,.46,-1,0);
309const boatGeo=new T.ExtrudeGeometry(boatShape,{depth:.20,bevelEnabled:true,bevelSize:.075,bevelThickness:.06,bevelSegments:3,steps:1});boatGeo.rotateX(-Math.PI/2);
310mesh(boat,boatGeo,M.wood);ell(boat,M.woodDark,[0,.215,0],[.88,.055,.41]);for(const xx of [-.46,.29])box(boat,M.wood,[xx,.26,0],[.14,.046,.73]);
311rod(boat,[-.45,.31,-.5],[.8,.3,.65],.023,M.woodDark);ell(boat,M.wood,[.88,.30,.75],[.18,.026,.09],true);
312tube(dock,[[.5,.08,2],[.65,-.05,2.4],[.9,-.1,2.5]],.010,M.cream,14,false);
313
314// Ducks leave animated rings on the water.
315const ducks=[];
316for(let i=0;i<4;i++){
317 const g=new T.Group();g.position.z=-5.5-i*.36;g.scale.setScalar(i===0?.72:.45);
318 ell(g,mat('#e6d7b3'),[0,0,0],[.28,.16,.15]);ell(g,mat('#708b71'),[.17,.15,0],[.112,.13,.11]);ell(g,mat('#bd8c56'),[.29,.13,0],[.08,.027,.052]);ell(g,M.eye,[.218,.18,.080],[.012,.012,.01],true,false);ell(g,mat('#aa9d78'),[-.015,.055,.11],[.16,.09,.052],true,false);
319 const ring=mesh(g,new T.RingGeometry(.28,.288,48),new T.MeshBasicMaterial({color:'#c0d5ba',transparent:true,opacity:.5,side:T.DoubleSide}),0,-.05,0,1,1,1,false);ring.rotation.x=-Math.PI/2;ring.scale.set(1.6,1,1);
320 scene.add(g);drift(g,-3.5-i*.7,96);ducks.push({g,phase:i});
321}
322
323// A few leaves and butterflies make the air feel alive.
324const leafShape=new T.Shape();leafShape.moveTo(0,-.08);leafShape.quadraticCurveTo(-.06,-.04,-.02,.035);leafShape.lineTo(0,.1);leafShape.quadraticCurveTo(.07,.04,0,-.08);
325const leafGeo=new T.ShapeGeometry(leafShape),leafMats=['#c89460','#d4b66b','#a6ad79'].map(c=>new T.MeshStandardMaterial({color:c,side:T.DoubleSide,roughness:.9}));
326const leaves=[];for(let i=0;i<22;i++){const l=mesh(scene,leafGeo,leafMats[i%3],rand(-12,12),rand(.5,5),rand(-4,4),1,1,1,false);leaves.push({l,x:l.position.x,y:l.position.y,z:l.position.z,phase:rand(0,TAU),rate:rand(.28,.8)});}
327const butterflies=[];
328for(let i=0;i<4;i++){const g=new T.Group();const wings=[];for(const s of [-1,1]){const w=ell(g,mat('#edc48b'),[0,0,s*.04],[.042,.01,.064],true,false);w.rotation.x=s*.3;wings.push(w);}ell(g,M.woodDark,[0,0,0],[.025,.015,.009],true,false);scene.add(g);butterflies.push({g,wings,phase:rand(0,TAU),x:rand(-7,7),z:rand(1.8,4)});}
329
330// A contact shadow under the cyclist supplements the soft sun shadow.
331const shadowTex=canvasTexture(128,128,(c,w,h)=>{const grd=c.createRadialGradient(w/2,h/2,0,w/2,h/2,w/2);grd.addColorStop(0,'rgba(48,49,29,.25)');grd.addColorStop(.45,'rgba(48,49,29,.14)');grd.addColorStop(1,'rgba(48,49,29,0)');c.fillStyle=grd;c.fillRect(0,0,w,h);});
332const contact=mesh(scene,new T.PlaneGeometry(3.6,1.5),new T.MeshBasicMaterial({map:shadowTex,transparent:true,depthWrite:false}),-.25,.002,0,1,1,1,false);contact.rotation.x=-Math.PI/2;
333
334// Batch static parts by material. Moving joints, cloth and instanced plants stay separate.
335function dynamic(o){o.userData.dynamic=true;}
336function batchStatic(root){
337 root.updateMatrixWorld(true);const inverse=root.matrixWorld.clone().invert(),buckets=new Map();
338 function collect(o){if(o!==root&&o.userData.dynamic)return;if(o.isMesh&&!o.isInstancedMesh&&!Array.isArray(o.material)){
339 const key=o.material.id+'|'+o.castShadow+'|'+o.receiveShadow;
340 if(!buckets.has(key))buckets.set(key,[]);buckets.get(key).push(o);
341 }for(const c of o.children)collect(c);}
342 collect(root);
343 for(const objects of buckets.values()){
344 if(objects.length<2)continue;const geometries=[];let count=0;
345 for(const o of objects){const g=o.geometry.index?o.geometry.toNonIndexed():o.geometry.clone();g.applyMatrix4(new T.Matrix4().multiplyMatrices(inverse,o.matrixWorld));if(!g.attributes.normal)g.computeVertexNormals();geometries.push(g);count+=g.attributes.position.count;}
346 const p=new Float32Array(count*3),n=new Float32Array(count*3),uv=new Float32Array(count*2);let posOffset=0,uvOffset=0;
347 for(const g of geometries){p.set(g.attributes.position.array,posOffset);n.set(g.attributes.normal.array,posOffset);if(g.attributes.uv)uv.set(g.attributes.uv.array,uvOffset);posOffset+=g.attributes.position.array.length;uvOffset+=g.attributes.position.count*2;g.dispose();}
348 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));g.computeBoundingSphere();
349 const first=objects[0],merged=new T.Mesh(g,first.material);merged.castShadow=first.castShadow;merged.receiveShadow=first.receiveShadow;objects.forEach(o=>o.parent.remove(o));root.add(merged);
350 }
351}
352eyes.forEach(dynamic);batchStatic(head);dynamic(head);
353for(const o of [torso,tail,scarf,...fringe])dynamic(o);
354for(const p of legParts){for(const o of [p.upper,p.lower,p.knee,p.foot,...p.claws])dynamic(o);}
355batchStatic(rider);dynamic(rider);
356batchStatic(cargo);dynamic(cargo);
357for(const w of wheels){batchStatic(w);dynamic(w);}
358dynamic(crankGroup);pedals.forEach(dynamic);batchStatic(bike);dynamic(bike);
359for(const p of plantGroups){batchStatic(p.crown);dynamic(p.crown);}
360for(const item of moving){batchStatic(item.group);dynamic(item.group);}
361dynamic(water);leaves.forEach(p=>dynamic(p.l));butterflies.forEach(b=>dynamic(b.g));
362batchStatic(scene);
363
364// Interaction: orbit, pinch/wheel zoom, camera presets, speed, pause and greeting.
365let playing=!matchMedia('(prefers-reduced-motion: reduce)').matches, speed=1, rideTime=0, distance=0, crankAngle=0, viewMode=0, greeting=0, hidden=false;
366const presets=[
367 {theta:.28,phi:1.39,size:8.3,target:V(0,1.6,-2.6),name:'旅伴视角'},
368 {theta:.48,phi:1.16,size:15.8,target:V(0,.7,-5.5),name:'河岸全景'},
369 {theta:.52,phi:1.36,size:4.7,target:V(-.2,1.68,-.02),name:'水獭特写'}
370];
371function preset(i){viewMode=i;Object.assign(cameraGoal,presets[i]);cameraGoal.target=presets[i].target.clone();$('viewLabel').textContent=presets[i].name;}
372function toast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').classList.remove('show'),2400);}
373function updatePlay(){ $('playIcon').innerHTML=playing?'<path d="M5 3h3v14H5zM12 3h3v14h-3z"/>':'<path d="M5 2l13 8L5 18z"/>'; $('play').setAttribute('aria-label',playing?'暂停动画':'继续动画');$('play').title=playing?'暂停(空格)':'继续(空格)';}
374function togglePlay(){playing=!playing;updatePlay();}
375$('play').addEventListener('click',togglePlay);updatePlay();
376$('speed').addEventListener('input',e=>{speed=+e.target.value;$('speedValue').textContent=(speed<.65?'慢慢':speed>1.4?'轻快':'悠然')+' · '+speed.toFixed(1)+'×';});
377$('view').addEventListener('click',()=>preset((viewMode+1)%3));
378$('reset').addEventListener('click',()=>{preset(0);toast('回到熟悉的河岸视角');});
379$('help').addEventListener('click',()=>{const open=$('helpPanel').classList.toggle('open');$('help').setAttribute('aria-expanded',String(open));});
380let audioContext;
381function ringBell(){greeting=1;toast('叮铃!愿你今天也有好心情。');try{audioContext??=new (window.AudioContext||window.webkitAudioContext)();audioContext.resume();const t=audioContext.currentTime;for(const delay of [0,.17])for(const freq of [1568,3136,4694]){const osc=audioContext.createOscillator(),gain=audioContext.createGain();osc.type='sine';osc.frequency.setValueAtTime(freq,t+delay);gain.gain.setValueAtTime(0,t+delay);gain.gain.linearRampToValueAtTime(freq===1568?.12:.022,t+delay+.005);gain.gain.exponentialRampToValueAtTime(.0001,t+delay+.9);osc.connect(gain);gain.connect(audioContext.destination);osc.start(t+delay);osc.stop(t+delay+1);}}catch(e){/* A visual greeting remains available if audio is disabled. */}}
382$('bell').addEventListener('click',ringBell);
383const ui=document.querySelectorAll('.heading,.top-actions,.postmark,.journey,.controls,.hint,.help-panel');
384function toggleUI(){hidden=!hidden;ui.forEach(el=>el.style.visibility=hidden?'hidden':'');if(hidden)toast('按 H 再次显示操作界面');}
385addEventListener('keydown',e=>{if(e.target.matches('input,textarea')||e.ctrlKey||e.altKey||e.metaKey)return;if(e.code==='Space'){e.preventDefault();togglePlay();}else if(e.key.toLowerCase()==='v')preset((viewMode+1)%3);else if(e.key.toLowerCase()==='r')preset(0);else if(e.key.toLowerCase()==='b')ringBell();else if(e.key.toLowerCase()==='h')toggleUI();else if(e.key==='Escape'){$('helpPanel').classList.remove('open');$('help').setAttribute('aria-expanded','false');}});
386const pointers=new Map();let lastPinch=0,clickStart=null,dragged=false;
387const canvas=renderer.domElement;
388canvas.addEventListener('pointerdown',e=>{pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});canvas.setPointerCapture(e.pointerId);clickStart={x:e.clientX,y:e.clientY};dragged=false;if(pointers.size===2){const a=[...pointers.values()];lastPinch=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);}});
389canvas.addEventListener('pointermove',e=>{if(!pointers.has(e.pointerId))return;const prev=pointers.get(e.pointerId);const dx=e.clientX-prev.x,dy=e.clientY-prev.y;pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});if(Math.abs(dx)+Math.abs(dy)>2)dragged=true;if(pointers.size===1){cameraGoal.theta-=dx*.005;cameraGoal.phi=clamp(cameraGoal.phi-dy*.004,.40,1.47);}else if(pointers.size===2){const a=[...pointers.values()],d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);if(lastPinch>0)cameraGoal.size=clamp(cameraGoal.size*lastPinch/Math.max(1,d),3.5,18);lastPinch=d;}});
390const raycaster=new T.Raycaster();
391canvas.addEventListener('pointerup',e=>{pointers.delete(e.pointerId);lastPinch=0;if(!dragged&&clickStart&&Math.hypot(e.clientX-clickStart.x,e.clientY-clickStart.y)<6){raycaster.setFromCamera(new T.Vector2(e.clientX/innerWidth*2-1,1-e.clientY/innerHeight*2),camera);if(raycaster.intersectObject(bike,true).length)ringBell();}clickStart=null;});
392canvas.addEventListener('pointercancel',e=>pointers.delete(e.pointerId));
393canvas.addEventListener('wheel',e=>{e.preventDefault();cameraGoal.size=clamp(cameraGoal.size*Math.exp(e.deltaY*.001),3.5,18);},{passive:false});
394let width=innerWidth,height=innerHeight;
395function resize(){width=innerWidth;height=innerHeight;renderer.setSize(width,height);renderer.setPixelRatio(Math.min(devicePixelRatio,1.75));}addEventListener('resize',resize);
396function updateCamera(dt){const k=1-Math.exp(-dt*9);cameraState.theta+= (cameraGoal.theta-cameraState.theta)*k;cameraState.phi+=(cameraGoal.phi-cameraState.phi)*k;cameraState.size+=(cameraGoal.size-cameraState.size)*k;cameraState.target.lerp(cameraGoal.target,k);const aspect=width/height,t=cameraState.target.clone(),r=13,p=cameraState.phi,a=cameraState.theta;if(aspect<1)t.x-=.65;camera.position.set(t.x+r*Math.sin(p)*Math.sin(a),t.y+r*Math.cos(p),t.z+r*Math.sin(p)*Math.cos(a));camera.lookAt(t);
397 const fit=aspect<1?1/Math.max(.58,aspect):1;camera.aspect=aspect;camera.fov=T.MathUtils.radToDeg(2*Math.atan(cameraState.size*fit/(2*r)));camera.updateProjectionMatrix();}
398
399// Time-based animation is independent of display refresh rate.
400let last=performance.now(),frames=0,first=true,uiTimer=0;
401function animate(now){requestAnimationFrame(animate);const dt=Math.min((now-last)/1000,.05);last=now;updateCamera(dt);
402 if(playing){const travelSpeed=2.10*speed;rideTime+=dt;distance+=dt*travelSpeed;crankAngle+=dt*travelSpeed/(wheelRadius*1.23)/1.55;
403 wheels.forEach(w=>w.rotation.z=-distance/(wheelRadius*1.23));crankGroup.rotation.z=-crankAngle;
404 for(let i=0;i<legParts.length;i++){const part=legParts[i],side=part.side,a=-crankAngle+(side===1?0:Math.PI);const px=-.1+Math.cos(a)*.205,py=.66+Math.sin(a)*.205,z=side*.246;
405 pedals[i].position.set(px,py,side*.21);
406 const hip=V(-.43,1.405,z),ankle=V(px-.035,py+.081,z);const dir=ankle.clone().sub(hip),d=Math.min(dir.length(),1.015),l1=.53,l2=.52;dir.normalize();const along=(l1*l1-l2*l2+d*d)/(2*d),bend=Math.sqrt(Math.max(.0001,l1*l1-along*along));const perpendicular=V(-dir.y,dir.x,0),knee=hip.clone().addScaledVector(dir,along).addScaledVector(perpendicular,bend);
407 limbBetween(part.upper,hip,knee,.145);limbBetween(part.lower,knee,ankle,.107);part.knee.position.copy(knee);part.foot.position.copy(ankle).add(V(.055,-.014,0));part.foot.rotation.z=.10*Math.sin(a);for(let j=0;j<3;j++)part.claws[j].position.copy(part.foot.position).add(V(.152,-.010,-.060+j*.055));
408 }
409 bike.rotation.x=Math.sin(crankAngle*2)*.010;bike.rotation.y=Math.sin(rideTime*.7)*.013;
410 const bounce=Math.sin(crankAngle*2)*.009;torso.position.y=1.73+bounce;head.position.y=2.28+bounce;
411 cargo.rotation.z=Math.sin(crankAngle*2-.4)*.013;cargo.rotation.x=Math.sin(rideTime*2)*.006;
412 const blinkCycle=rideTime%4.7;const blink=blinkCycle<.16?Math.max(.08,Math.abs(blinkCycle-.08)/.08):1;eyes.forEach(e=>e.scale.y=blink);
413 head.rotation.y=Math.sin(rideTime*.6)*.055-greeting*.22;head.rotation.z=Math.sin(rideTime*.85)*.017;
414 updateTail(rideTime);updateScarf(rideTime,speed);
415 riverUniforms.time.value=rideTime;riverUniforms.shift.value=distance;
416 pathTexture.offset.x=(distance/(240/55))%1;
417 for(const item of moving){item.group.position.x=((item.x-distance+item.span/2)%item.span+item.span)%item.span-item.span/2;}
418 for(const p of plantGroups){p.crown.rotation.z=Math.sin(rideTime*.85+p.phase)*.015;}
419 boat.rotation.x=Math.sin(rideTime*1.1)*.02;boat.rotation.z=Math.sin(rideTime*.8)*.015;boat.position.y=-.20+Math.sin(rideTime*.95)*.02;
420 for(const d of ducks){d.g.position.y=-.12+Math.sin(rideTime*2+d.phase)*.012;d.g.rotation.y=Math.sin(rideTime*.5+d.phase)*.08;d.g.position.x+=Math.sin(rideTime*.12)*.4;}
421 for(const p of leaves){p.l.position.set(((p.x-distance*.65-rideTime*p.rate+14)%28+28)%28-14,p.y+Math.sin(rideTime*.8+p.phase)*.35,p.z+Math.sin(rideTime*.6+p.phase)*.35);p.l.rotation.set(rideTime*.8+p.phase,rideTime*.6+p.phase,Math.sin(rideTime+p.phase));}
422 for(const b of butterflies){b.g.position.set(((b.x-distance*.4+12)%24+24)%24-12,.65+Math.sin(rideTime*1.7+b.phase)*.22,b.z+Math.sin(rideTime*.8+b.phase)*.7);b.g.rotation.y=Math.sin(rideTime+b.phase);for(let j=0;j<2;j++)b.wings[j].rotation.x=(j===0?-1:1)*Math.sin(rideTime*19+b.phase)*.9;}
423 }
424 if(greeting>0)greeting=Math.max(0,greeting-dt*.8);
425 uiTimer+=dt;if(uiTimer>.2){uiTimer=0;$('distance').textContent=String(Math.floor(distance)).padStart(3,'0');$('journeyBar').style.width=(20+(distance%120)/120*80)+'%';}
426 renderer.render(scene,camera);frames++;
427 if(first){first=false;$('loading').classList.add('hide');setTimeout(()=>$('loading').remove(),900);}
428}
429// Initialize even when reduced motion starts the film paused.
430updateTail(0);updateScarf(0,1);
431const initialPlaying=playing;playing=true;animate(performance.now()+16);playing=initialPlaying;updatePlay();
432document.addEventListener('visibilitychange',()=>{last=performance.now();});
433// A read-only diagnostic object is useful for verifying the delivered file.
434window.riverPost={get state(){return {playing,speed,distance,view:viewMode,frames,drawCalls:renderer.info.render.calls,triangles:renderer.info.render.triangles,three:T.REVISION};}};
435})();