← 返回本轮质量对比

A · GPT-6.1 Sol / xhigh · 原场景源码

原JS · 完整原源码ZIP
1import * as THREE from 'three';
2import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3
4const $ = (id) => document.getElementById(id);
5const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
6let paused = false, speed = 1, time = 0, distance = 0, sunsetTarget = 0, sunsetMix = 0;
7let view = 'coast', cameraTransition = 0, toastTimer;
8function toast(message) { $('toast').textContent = message; $('toast').classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => $('toast').classList.remove('show'), 2200); }
9
10try { init(); } catch (error) { console.error(error); $('loading').classList.add('failed'); $('error-message').textContent = '请使用支持 WebGL 2 的 Chrome、Edge 或 Safari,并开启浏览器硬件加速后重试。'; }
11
12function init() {
13 const scene = new THREE.Scene();
14 scene.fog = new THREE.Fog(0xb6dfe4, 48, 175);
15 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
16 renderer.setPixelRatio(Math.min(devicePixelRatio, innerWidth < 680 ? 1.5 : 2));
17 renderer.setSize(innerWidth, innerHeight);
18 renderer.shadowMap.enabled = true;
19 renderer.shadowMap.type = THREE.PCFSoftShadowMap;
20 renderer.outputColorSpace = THREE.SRGBColorSpace;
21 renderer.toneMapping = THREE.ACESFilmicToneMapping;
22 renderer.toneMappingExposure = 1.18;
23 $('scene').appendChild(renderer.domElement);
24 renderer.domElement.setAttribute('aria-label', '可拖动旋转、滚轮缩放的猫咪海岸骑行场景');
25 const camera = new THREE.PerspectiveCamera(42, innerWidth / innerHeight, .1, 420);
26 const controls = new OrbitControls(camera, renderer.domElement);
27 controls.enableDamping = true; controls.dampingFactor = .06; controls.enablePan = false;
28 controls.minDistance = 4.2; controls.maxDistance = 22;
29 controls.minPolarAngle = .25; controls.maxPolarAngle = Math.PI / 2 - .035;
30 const isMobile = () => innerWidth < 680;
31 function preset(name) {
32 const mobile = isMobile();
33 if (name === 'close') return { position: new THREE.Vector3(4.2, 2.9, mobile ? 7.4 : 5.6), target: new THREE.Vector3(.1, 1.55, .8) };
34 if (name === 'follow') return { position: new THREE.Vector3(mobile ? -10 : -8.4, 3.5, mobile ? 6 : 4.4), target: new THREE.Vector3(1.3, 1.3, .35) };
35 return { position: new THREE.Vector3(mobile ? 8.8 : 6.8, mobile ? 4.6 : 3.8, mobile ? 13.6 : 10), target: new THREE.Vector3(0, 1.3, -.8) };
36 }
37 let cameraGoal = preset(view);
38 camera.position.copy(cameraGoal.position); controls.target.copy(cameraGoal.target); controls.update();
39 controls.addEventListener('start', () => { cameraTransition = 0; document.querySelectorAll('[data-view]').forEach(b => b.setAttribute('aria-pressed', 'false')); view = 'free'; });
40
41 const ambient = new THREE.HemisphereLight(0xc8edff, 0xc3b791, 2.25); scene.add(ambient);
42 const sun = new THREE.DirectionalLight(0xfff0c6, 3.6); sun.position.set(-9, 17, 10); sun.castShadow = true;
43 sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -18; sun.shadow.camera.right = 18;
44 sun.shadow.camera.top = 15; sun.shadow.camera.bottom = -15; sun.shadow.camera.near = .5; sun.shadow.camera.far = 55;
45 sun.shadow.normalBias = .035; sun.shadow.bias = -.00008; sun.shadow.radius = 3; scene.add(sun); scene.add(sun.target);
46
47 const skyUniforms = { uTop: { value: new THREE.Color('#58b9db') }, uHorizon: { value: new THREE.Color('#d5eee8') }, uSun: { value: new THREE.Vector3(-.45,.44,-.75).normalize() }, uSunColor: { value: new THREE.Color('#fff2bb') } };
48 const sky = new THREE.Mesh(new THREE.SphereGeometry(350, 32, 16), new THREE.ShaderMaterial({
49 side: THREE.BackSide, depthWrite: false, uniforms: skyUniforms,
50 vertexShader: 'varying vec3 vDir; void main(){vDir=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',
51 fragmentShader: 'varying vec3 vDir; uniform vec3 uTop,uHorizon,uSun,uSunColor; void main(){vec3 d=normalize(vDir);float h=smoothstep(-.05,.82,d.y);vec3 c=mix(uHorizon,uTop,h);float s=dot(d,uSun);c=mix(c,uSunColor,smoothstep(.9978,.9988,s));c+=uSunColor*.07*pow(max(s,0.),48.);gl_FragColor=vec4(c,1.);\n#include <tonemapping_fragment>\n#include <colorspace_fragment>\n}'
52 })); scene.add(sky);
53
54 const mat = (color, roughness = .75, metalness = 0) => new THREE.MeshStandardMaterial({ color, roughness, metalness });
55 const materials = {
56 asphalt: mat('#6b7b83'), sand: mat('#f1dbab'), edge: mat('#e4ddc9'), white: mat('#fff6df'),
57 grass: mat('#88bb7f'), trunk: mat('#b68b5e'), leaf: mat('#4b966f'), leaf2: mat('#65b481'),
58 teal: mat('#56c8bb', .28, .18), tealDark: mat('#239b98', .4, .15), cream: mat('#fdf0d5', .45),
59 orange: mat('#eba451'), orangeDark: mat('#bd713b'), muzzle: mat('#fff1d6'), nose: mat('#cb746a'),
60 eyes: mat('#233e43', .25), tire: mat('#27383e'), metal: mat('#becbd0', .25, .75), seat: mat('#754f3c'),
61 coral: mat('#ed785b'), light: new THREE.MeshStandardMaterial({color:'#fff2c2',emissive:'#ffe9a4',emissiveIntensity:.5,roughness:.3}),
62 seaRock: mat('#97b3a2'), hill: mat('#6d9d89')
63 };
64 const geos = { sphere: new THREE.SphereGeometry(1, 24, 16), box: new THREE.BoxGeometry(1,1,1), cylinder: new THREE.CylinderGeometry(1,1,1,16) };
65 function mesh(geo, material, parent, p = [0,0,0], s = [1,1,1], shadows = true) {
66 const m = new THREE.Mesh(geo, material); m.position.set(...p); m.scale.set(...s); m.castShadow = shadows; m.receiveShadow = shadows; parent.add(m); return m;
67 }
68 const sphere = (parent,m,p,s,shadows=true) => mesh(geos.sphere,m,parent,p,s,shadows);
69 const box = (parent,m,p,s,shadows=true) => mesh(geos.box,m,parent,p,s,shadows);
70 function rod(parent,m,a,b,r=.04) {
71 const start = new THREE.Vector3(...a), end = new THREE.Vector3(...b), d = end.clone().sub(start);
72 const obj = mesh(geos.cylinder,m,parent,start.clone().add(end).multiplyScalar(.5).toArray(),[r,d.length(),r]);
73 obj.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),d.normalize()); return obj;
74 }
75 function tube(parent,m,points,r=.035,segments=24) {
76 return mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points.map(p=>new THREE.Vector3(...p))),segments,r,7,false),m,parent);
77 }
78
79 // Ocean, shoreline, and a continuous two-lane coast road.
80 const oceanUniforms = { uTime:{value:0}, uDeep:{value:new THREE.Color('#197e9c')}, uShallow:{value:new THREE.Color('#68d3cc')}, uFoam:{value:new THREE.Color('#e0fbec')} };
81 const ocean = new THREE.Mesh(new THREE.PlaneGeometry(450,230,160,90),new THREE.ShaderMaterial({
82 uniforms:oceanUniforms, side:THREE.DoubleSide,
83 vertexShader:'uniform float uTime; varying vec3 vWorld; void main(){vec3 p=position;p.z+=sin(p.x*.38+uTime*1.2)*.055+sin(p.y*.63+uTime*.95)*.045;vec4 w=modelMatrix*vec4(p,1.);vWorld=w.xyz;gl_Position=projectionMatrix*viewMatrix*w;}',
84 fragmentShader:'uniform float uTime;uniform vec3 uDeep,uShallow,uFoam;varying vec3 vWorld;void main(){float coast=smoothstep(-52.,-8.,vWorld.z);float n=sin(vWorld.x*.8+sin(vWorld.z*.31+uTime*.6)*2.+uTime*.6)*sin(vWorld.z*1.5-uTime*.85);float glint=pow(max(n,0.),24.);float swell=sin(vWorld.z*1.65+sin(vWorld.x*.26)*.7+uTime*1.4);float foam=pow(max(swell,0.),20.)*coast*.27;vec3 c=mix(uDeep,uShallow,coast*.83)+n*.018;c=mix(c,uFoam,glint*.42+foam);float fog=smoothstep(90.,220.,length(vWorld.xz));c=mix(c,vec3(.67,.84,.85),fog);gl_FragColor=vec4(c,1.);\n#include <tonemapping_fragment>\n#include <colorspace_fragment>\n}'
85 })); ocean.rotation.x=-Math.PI/2; ocean.position.set(0,-.14,-121); scene.add(ocean);
86 box(scene,materials.sand,[0,-.33,-5.5],[350,.6,5]);
87 box(scene,materials.asphalt,[0,-.12,0],[350,.24,6.8]);
88 box(scene,materials.edge,[0,-.09,-3.65],[350,.22,.56]);
89 box(scene,materials.edge,[0,-.08,3.65],[350,.26,.56]);
90 box(scene,materials.sand,[0,-.18,8],[350,.28,8.5]);
91 box(scene,materials.grass,[0,-.26,18],[350,.35,15]);
92 for(const z of [-3.15,3.15]) box(scene,materials.white,[0,.006,z],[350,.012,.075],false);
93
94 const moving = new THREE.Group(); scene.add(moving); const loops=[];
95 const period=120;
96 function loopObject(object,x){object.position.x=x; moving.add(object);loops.push({object,x});}
97 for(let x=-60;x<60;x+=5) { const m=box(new THREE.Group(),materials.white,[0,.012,0],[2.6,.02,.105],false); loopObject(m,x); }
98 // A low seawall, its posts and reflective road bollards pass by the rider.
99 box(scene,materials.edge,[0,.36,-3.91],[350,.17,.19]);
100 for(let x=-60;x<60;x+=6){const g=new THREE.Group();box(g,materials.edge,[0,.21,-3.91],[.17,.6,.23]);box(g,materials.white,[0,.48,3.65],[.12,.85,.13]);box(g,materials.coral,[0,.65,3.65],[.125,.15,.14]);loopObject(g,x);}
101
102 let seed=9137;
103 const rand=()=>{seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;};
104 function palm(height=4.4){
105 const g=new THREE.Group();
106 const bend=.35;
107 tube(g,materials.trunk,[[0,0,0],[.12,height*.4,0],[bend,height*.85,.06],[bend,height,.07]],.125,12);
108 for(let i=1;i<9;i++){const h=height*i/9;const ring=mesh(new THREE.TorusGeometry(.13,.018,5,12),materials.trunk,g,[bend*(i/9)**2,h,.04]);ring.rotation.x=Math.PI/2;}
109 for(let i=0;i<8;i++){
110 const a=i*Math.PI/4+.13;
111 const vertices=[],indices=[],length=1.9+rand()*.7;
112 const steps=10;
113 for(let j=0;j<=steps;j++){
114 const t=j/steps,d=length*t,w=Math.sin(t*Math.PI)**.65*.24;
115 const y=height+.07+Math.sin(t*Math.PI)*.35-t*t*.73;
116 for(const side of [-1,1]) vertices.push(bend+Math.cos(a)*d-Math.sin(a)*w*side,y+(j%2)*.025,.07+Math.sin(a)*d+Math.cos(a)*w*side);
117 if(j<steps){const q=j*2;indices.push(q,q+1,q+2,q+1,q+3,q+2);}
118 }
119 const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute(vertices,3));geo.setIndex(indices);geo.computeVertexNormals();
120 const leafmat=(i%2?materials.leaf:materials.leaf2);leafmat.side=THREE.DoubleSide;mesh(geo,leafmat,g);
121 tube(g,materials.leaf,[[bend,height,.07],[bend+Math.cos(a)*length*.5,height+.25,.07+Math.sin(a)*length*.5],[bend+Math.cos(a)*length,height-.66,.07+Math.sin(a)*length]],.022,8);
122 }
123 for(let i=0;i<3;i++)sphere(g,materials.trunk,[bend+Math.cos(i*2)*.18,height-.15,.07+Math.sin(i*2)*.18],[.14,.18,.14]);
124 return g;
125 }
126 for(let x=-51;x<60;x+=17){const g=palm(3.8+rand()*.9);g.position.z=-5.45;g.rotation.y=rand()*2;loopObject(g,x);}
127 for(let x=-60;x<60;x+=26){const g=palm(4.2+rand());g.position.z=6.6+rand()*2.5;g.rotation.y=rand()*3;loopObject(g,x);}
128 // Beach stones and low dune grass.
129 for(let i=0;i<32;i++){
130 const g=new THREE.Group();g.position.z=4.4+rand()*5;
131 if(i%3===0)mesh(new THREE.DodecahedronGeometry(.25+rand()*.3,0),materials.seaRock,g,[0,.12,0],[1,.65,.8]);
132 else for(let j=0;j<5;j++)rod(g,materials.leaf,[0,0,0],[(rand()-.5)*.35,.18+rand()*.35,(rand()-.5)*.3],.012);
133 loopObject(g,-60+rand()*120);
134 }
135 function sign(){const g=new THREE.Group();rod(g,materials.metal,[0,0,0],[0,2.8,0],.045);box(g,materials.tealDark,[0,2.55,0],[1.7,.7,.085]);
136 const c=document.createElement('canvas');c.width=512;c.height=220;const ctx=c.getContext('2d');ctx.fillStyle='#218982';ctx.fillRect(0,0,512,220);ctx.strokeStyle='#d9f0d5';ctx.lineWidth=7;ctx.strokeRect(10,10,492,200);ctx.fillStyle='#fff9e5';ctx.font='bold 57px sans-serif';ctx.textAlign='center';ctx.fillText('海岸公路',256,95);ctx.font='28px sans-serif';ctx.fillText('COAST ROAD · 08',256,154);
137 const tex=new THREE.CanvasTexture(c);tex.colorSpace=THREE.SRGBColorSpace;mesh(new THREE.PlaneGeometry(1.65,.7),new THREE.MeshBasicMaterial({map:tex}),g,[0,2.55,.052],undefined,false);g.position.z=-4.6;return g;}
138 loopObject(sign(),31);
139
140 // Distant islands, sailboats, and soft clouds remain on the horizon.
141 for(const [x,z,s] of [[-43,-85,1.1],[32,-112,1.7],[72,-145,1.1]]){
142 const g=new THREE.Group();g.position.set(x,-.4,z);
143 sphere(g,materials.sand,[0,.1,0],[13*s,.6,5*s],false);
144 mesh(new THREE.ConeGeometry(9*s,7*s,8),materials.hill,g,[-3*s,2.2*s,0],[1,1,.65],false);
145 mesh(new THREE.ConeGeometry(6*s,5*s,7),materials.seaRock,g,[6*s,1.5*s,0],[1,1,.7],false);scene.add(g);
146 }
147 const boats=[];
148 function boat(x,z,scale){const g=new THREE.Group();g.position.set(x,-.06,z);g.scale.setScalar(scale);
149 sphere(g,materials.cream,[0,.13,0],[1.1,.23,.35],false);rod(g,materials.trunk,[0,.2,0],[0,2.9,0],.035);
150 const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute([.08,.5,0,.08,2.85,0,1.35,.5,0],3));geo.computeVertexNormals();const sail=materials.white.clone();sail.side=THREE.DoubleSide;mesh(geo,sail,g,undefined,undefined,false);scene.add(g);boats.push({g,x,z});}
151 boat(-19,-35,.8);boat(25,-49,1.1);
152 const cloudMat=mat('#f4fcf7');const clouds=[];
153 for(let i=0;i<11;i++){
154 const g=new THREE.Group(),x=-85+i*18,z=-68-rand()*46,y=12+rand()*13;
155 for(let j=0;j<5;j++)sphere(g,cloudMat,[(j-2)*1.8,Math.sin(j)*.4,0],[2.1,1+rand()*.6,1.25],false);
156 g.position.set(x,y,z);scene.add(g);clouds.push({g,x});
157 }
158
159 // Detailed electric scooter. Forward is +X; the wheel axles point along Z.
160 const rider=new THREE.Group();rider.position.set(0,0,1.32);scene.add(rider);
161 const scooter=new THREE.Group();rider.add(scooter);
162 const wheels=[];
163 for(const x of [-.96,.98]){
164 const g=new THREE.Group();g.position.set(x,.47,0);scooter.add(g);
165 mesh(new THREE.TorusGeometry(.345,.108,12,32),materials.tire,g);
166 const rim=mesh(new THREE.CylinderGeometry(.245,.245,.135,24),materials.cream,g);rim.rotation.x=Math.PI/2;
167 for(const side of [-1,1]){
168 const hub=mesh(new THREE.CylinderGeometry(.07,.07,.022,16),materials.tealDark,g,[0,0,side*.083]);hub.rotation.x=Math.PI/2;
169 for(let j=0;j<6;j++){const a=j*Math.PI/3;rod(g,materials.metal,[Math.cos(a)*.07,Math.sin(a)*.07,side*.085],[Math.cos(a)*.20,Math.sin(a)*.20,side*.085],.018);}
170 }wheels.push(g);
171 }
172 box(scooter,materials.tealDark,[-.06,.56,0],[1.9,.14,.68]);
173 box(scooter,materials.cream,[.07,.647,0],[1.33,.055,.57]);
174 for(let i=0;i<5;i++)box(scooter,materials.tealDark,[.07,.678,-.23+i*.115],[.95,.006,.022],false);
175 sphere(scooter,materials.teal,[-.87,.85,0],[.57,.32,.41]);
176 sphere(scooter,materials.teal,[-.61,1.08,0],[.5,.32,.34]);
177 sphere(scooter,materials.seat,[-.55,1.32,0],[.59,.13,.39]);
178 rod(scooter,materials.metal,[-1.04,.49,-.06],[-.50,1.08,-.06],.065);
179 rod(scooter,materials.metal,[.98,.47,0],[.82,1.7,0],.075);
180 sphere(scooter,materials.teal,[.87,1.09,0],[.26,.65,.41]);
181 sphere(scooter,materials.cream,[1.07,1.13,0],[.06,.41,.285]);
182 const frontFender=mesh(new THREE.TorusGeometry(.445,.085,10,28,Math.PI),materials.teal,scooter,[.98,.48,0],[1,1,1]);
183 const rearFender=mesh(new THREE.TorusGeometry(.445,.06,10,28,Math.PI),materials.teal,scooter,[-.96,.47,0]);
184 sphere(scooter,materials.teal,[.85,1.84,0],[.245,.175,.26]);
185 const lamp=mesh(new THREE.CylinderGeometry(.125,.125,.06,32),materials.light,scooter,[1.071,1.85,0]);lamp.rotation.z=-Math.PI/2;
186 const lampRim=mesh(new THREE.TorusGeometry(.135,.02,8,28),materials.metal,scooter,[1.11,1.85,0]);lampRim.rotation.y=Math.PI/2;
187 rod(scooter,materials.metal,[.78,1.88,-.48],[.78,1.88,.48],.04);
188 for(const z of [-.47,.47]){
189 rod(scooter,materials.tire,[.78,1.88,z-.07],[.78,1.88,z+.07],.055);
190 const sz=Math.sign(z);rod(scooter,materials.metal,[.78,1.91,z],[.84,2.24,z+sz*.11],.022);
191 sphere(scooter,materials.teal,[.84,2.24,z+sz*.11],[.06,.10,.14]);
192 sphere(scooter,materials.metal,[.895,2.24,z+sz*.11],[.012,.074,.115]);
193 sphere(scooter,materials.coral,[1.01,1.51,sz*.33],[.07,.06,.08]);
194 }
195 box(scooter,materials.coral,[-1.33,.97,0],[.06,.12,.19]);
196 const plate=box(scooter,materials.white,[-1.34,.77,0],[.028,.16,.31]);plate.rotation.z=-.12;
197 rod(scooter,materials.metal,[-1.06,1.17,-.32],[-1.06,1.17,.32],.028);
198 for(const z of [-.29,.29])rod(scooter,materials.metal,[-1.25,1.17,z],[-.88,1.17,z],.022);
199 // Tiny lightning badge on the electric scooter's side.
200 const boltShape=new THREE.Shape();boltShape.moveTo(-.03,.12);boltShape.lineTo(-.1,-.015);boltShape.lineTo(.015,-.015);boltShape.lineTo(-.035,-.12);boltShape.lineTo(.11,.04);boltShape.lineTo(0,.04);boltShape.closePath();
201 for(const z of [-.404,.404])mesh(new THREE.ShapeGeometry(boltShape),new THREE.MeshBasicMaterial({color:'#fff3ce',side:THREE.DoubleSide}),scooter,[-.92,.92,z],[.8,.8,.8],false);
202
203 // Orange cat: paws on the handlebars, feet on the deck, helmet and wind-blown scarf.
204 const cat=new THREE.Group();rider.add(cat);
205 sphere(cat,materials.orange,[-.43,1.79,0],[.31,.5,.31]);
206 sphere(cat,materials.muzzle,[-.175,1.75,0],[.052,.32,.235]);
207 sphere(cat,materials.orange,[-.55,1.44,-.19],[.28,.24,.21]);sphere(cat,materials.orange,[-.55,1.44,.19],[.28,.24,.21]);
208 const head=new THREE.Group();head.position.set(-.28,2.4,0);cat.add(head);
209 sphere(head,materials.orange,[0,0,0],[.48,.43,.43]);
210 // Conical ears with inner pink triangles.
211 for(const z of [-.29,.29]){
212 const ear=mesh(new THREE.ConeGeometry(.175,.39,3),materials.orange,head,[-.065,.405,z]);ear.rotation.y=Math.PI/2;ear.rotation.x=Math.sign(z)*.19;
213 const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute([.063,.37,z-.11,.063,.37,z+.11,.063,.64,z],3));geo.computeVertexNormals();mesh(geo,new THREE.MeshStandardMaterial({color:'#eaa69a',side:THREE.DoubleSide,roughness:.9}),head,undefined,undefined,false);
214 }
215 for(const z of [-.22,.22]){
216 sphere(head,materials.eyes,[.409,.055,z],[.07,.105,.062]);
217 sphere(head,materials.white,[.464,.091,z+.012],[.018,.024,.015],false);
218 sphere(head,materials.muzzle,[.417,-.125,z*.48],[.105,.115,.14]);
219 sphere(head,materials.nose,[.364,-.105,Math.sign(z)*.324],[.012,.062,.087]);
220 for(let k=0;k<3;k++)rod(head,materials.seat,[.432,-.13,z*.84],[.47,-.12+(k-1)*.076,Math.sign(z)*(.44+k*.025)],.008);
221 }
222 sphere(head,materials.nose,[.516,-.082,0],[.049,.036,.047]);
223 tube(head,materials.seat,[[.509,-.13,0],[.502,-.17,0],[.479,-.19,-.07]],.009,8);
224 tube(head,materials.seat,[[.502,-.17,0],[.479,-.19,.07]],.009,8);
225 // Tabby markings are raised curved fur patches, visible on both cheeks.
226 for(const z of [-.41,.41])for(let k=0;k<2;k++){
227 const stripe=sphere(head,materials.orangeDark,[-.13+k*.13,.025-k*.09,z],[.045,.13,.013]);stripe.rotation.z=-.45;
228 }
229 for(let k=-1;k<=1;k++)sphere(head,materials.orangeDark,[.33,.273,k*.10],[.022,.11,.031]);
230 const helmet=mesh(new THREE.SphereGeometry(.50,32,18,0,Math.PI*2,0,Math.PI*.45),materials.teal,head,[-.025,.065,0],[1,1,.95]);
231 const helmetRim=mesh(new THREE.TorusGeometry(.49,.025,8,32),materials.cream,head,[-.025,.143,0],[1,1,.95]);helmetRim.rotation.x=Math.PI/2;
232 // Two longitudinal cream helmet stripes.
233 for(const z of [-.09,.09])tube(head,materials.cream,[[.418,.235,z],[.26,.47,z],[-.02,.565,z],[-.30,.46,z],[-.47,.22,z]],.025,20);
234 sphere(head,materials.teal,[.42,.18,0],[.16,.033,.42]);
235 for(const z of [-.32,.32])tube(head,materials.seat,[[-.22,.08,z],[-.12,-.34,z*.9],[.17,-.34,z*.7]],.016,10);
236 const arms=[];
237 for(const z of [-1,1]){
238 const g=new THREE.Group();cat.add(g);arms.push(g);
239 rod(g,materials.orange,[-.31,1.99,z*.23],[.17,1.76,z*.35],.105);
240 sphere(g,materials.orange,[.17,1.76,z*.35],[.12,.11,.11]);
241 rod(g,materials.orange,[.17,1.76,z*.35],[.72,1.89,z*.46],.082);
242 sphere(g,materials.muzzle,[.75,1.89,z*.47],[.12,.09,.092]);
243 rod(cat,materials.orange,[-.47,1.46,z*.24],[-.05,1.04,z*.36],.13);
244 sphere(cat,materials.orange,[-.05,1.04,z*.36],[.15,.16,.14]);
245 rod(cat,materials.orange,[-.05,1.04,z*.36],[.17,.72,z*.32],.105);
246 sphere(cat,materials.muzzle,[.26,.73,z*.33],[.21,.08,.13]);
247 for(let j=0;j<2;j++)rod(cat,materials.orangeDark,[.35,.786,z*.33+(j-.5)*.07],[.41,.764,z*.33+(j-.5)*.07],.008);
248 }
249 const tailPivot=new THREE.Group();tailPivot.position.set(-.74,1.49,-.10);cat.add(tailPivot);
250 tube(tailPivot,materials.orange,[[0,0,0],[-.43,-.08,-.09],[-.77,.03,-.16],[-1.05,.28,-.12],[-1.02,.60,.04],[-.86,.73,.11]],.09,32);
251 sphere(tailPivot,materials.orangeDark,[-.86,.73,.11],[.091,.092,.095]);
252 for(const p of [[-.47,-.06,-.10],[-.83,.07,-.16],[-1.03,.31,-.10]])sphere(tailPivot,materials.orangeDark,p,[.035,.095,.095]);
253 const scarf=new THREE.Group();cat.add(scarf);
254 const collar=mesh(new THREE.TorusGeometry(.243,.067,10,28),materials.coral,scarf,[-.35,2.055,0],[1,1,1.06]);collar.rotation.x=Math.PI/2;
255 sphere(scarf,materials.coral,[-.55,2.10,.23],[.115,.10,.10]);
256 const scarfGeo=new THREE.PlaneGeometry(1.0,.16,14,1);const scarfMat=materials.coral.clone();scarfMat.side=THREE.DoubleSide;
257 const scarfFlap=mesh(scarfGeo,scarfMat,scarf,[-1.10,2.08,.24],undefined,false);
258 const scarfBase=scarfGeo.attributes.position.array.slice();
259
260 // Soft contact shadow keeps the scooter grounded even with a low sun.
261 const shadowCanvas=document.createElement('canvas');shadowCanvas.width=128;shadowCanvas.height=128;const sc=shadowCanvas.getContext('2d');const grad=sc.createRadialGradient(64,64,5,64,64,62);grad.addColorStop(0,'rgba(20,38,40,.28)');grad.addColorStop(.5,'rgba(20,38,40,.13)');grad.addColorStop(1,'rgba(20,38,40,0)');sc.fillStyle=grad;sc.fillRect(0,0,128,128);
262 const contact=mesh(new THREE.PlaneGeometry(3.8,1.8),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(shadowCanvas),transparent:true,depthWrite:false}),scene,[0,.026,1.32],undefined,false);contact.rotation.x=-Math.PI/2;
263
264 const birds=[];
265 for(let i=0;i<7;i++){
266 const g=new THREE.Group(),left=new THREE.Group(),right=new THREE.Group();g.add(left,right);
267 sphere(g,materials.white,[0,0,0],[.12,.06,.05],false);
268 const wingGeo=new THREE.BufferGeometry();wingGeo.setAttribute('position',new THREE.Float32BufferAttribute([0,0,0,.13,.025,.2,0,0,.50],3));wingGeo.computeVertexNormals();
269 const wingMat=materials.white.clone();wingMat.side=THREE.DoubleSide;mesh(wingGeo,wingMat,right,undefined,undefined,false);mesh(wingGeo,wingMat,left,[0,0,0],[1,1,-1],false);
270 g.position.set(-12+i*4,5+rand()*3,-16-rand()*17);scene.add(g);birds.push({g,left,right,x:g.position.x,y:g.position.y,z:g.position.z,phase:rand()*6});
271 }
272
273 const day={top:new THREE.Color('#58b9db'),horizon:new THREE.Color('#d5eee8'),fog:new THREE.Color('#b6dfe4'),sun:new THREE.Color('#fff0c6'),ambient:new THREE.Color('#c8edff'),deep:new THREE.Color('#197e9c'),shallow:new THREE.Color('#68d3cc')};
274 const dusk={top:new THREE.Color('#7d9fbe'),horizon:new THREE.Color('#ffd1a1'),fog:new THREE.Color('#dcc8b5'),sun:new THREE.Color('#ffb975'),ambient:new THREE.Color('#c6bfdb'),deep:new THREE.Color('#376f8a'),shallow:new THREE.Color('#72b8b2')};
275 function updatePlay(){ $('play').dataset.paused=String(paused);$('play').setAttribute('aria-label',paused?'继续骑行':'暂停骑行');$('play').setAttribute('aria-pressed',String(paused)); }
276 updatePlay();
277 $('play').addEventListener('click',()=>{paused=!paused;updatePlay();});
278 function setSpeed(value){speed=value;$('speed').value=String(speed);$('speed-value').value=speed.toFixed(1)+'×';$('speed').setAttribute('aria-valuetext',speed.toFixed(1)+' 倍速度');}
279 function setView(name){view=name;cameraGoal=preset(view);cameraTransition=1;if(reducedMotion){camera.position.copy(cameraGoal.position);controls.target.copy(cameraGoal.target);cameraTransition=0;}document.querySelectorAll('[data-view]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.view===view)));}
280 $('speed').addEventListener('input',e=>setSpeed(Number(e.target.value)));
281 document.querySelectorAll('[data-view]').forEach(button=>button.addEventListener('click',()=>setView(button.dataset.view)));
282 $('sunset').addEventListener('click',()=>{sunsetTarget=1-sunsetTarget;$('sunset').setAttribute('aria-pressed',String(!!sunsetTarget));$('sunset').setAttribute('aria-label',sunsetTarget?'切换到晴天':'切换到日落');$('sunset').querySelector('span').textContent=sunsetTarget?'晴天海岸':'日落时分';$('weather-label').textContent=sunsetTarget?'日落 · 追着晚风':'晴天 · 海风轻拂';});
283 $('fullscreen').addEventListener('click',async()=>{try{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen();}catch{toast('当前窗口暂不支持全屏,可在浏览器中打开。');}});
284 document.addEventListener('fullscreenchange',()=>{$('fullscreen').setAttribute('aria-label',document.fullscreenElement?'退出全屏':'进入全屏');});
285 document.addEventListener('keydown',e=>{if(e.code==='Space'&&e.target===document.body){e.preventDefault();paused=!paused;updatePlay();}});
286 renderer.domElement.addEventListener('webglcontextlost',e=>{e.preventDefault();$('loading').classList.remove('hidden');$('loading').classList.add('failed');$('error-message').textContent='图形显示已中断,请重新加载页面。';});
287 window.addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,isMobile()?1.5:2));if(view!=='free'){cameraGoal=preset(view);cameraTransition=1;}});
288
289 let previous=performance.now(),frames=0;
290 function render(now){
291 const dt=Math.min((now-previous)/1000,.06);previous=now;
292 const motion=paused?0:dt*speed;
293 time+=motion;distance+=motion*5.56/1000;
294 const offset=(time*5.56)%period;
295 for(const {object,x} of loops)object.position.x=((x-offset+60)%period+period)%period-60;
296 for(const wheel of wheels)wheel.rotation.z=-time*5.56/.445;
297 rider.position.y=Math.sin(time*5)*.018;rider.rotation.x=Math.sin(time*1.35)*.022;rider.rotation.z=Math.sin(time*2.1)*.008;
298 head.rotation.y=Math.sin(time*.7)*.055;head.rotation.z=Math.sin(time*2)*.015;
299 tailPivot.rotation.x=Math.sin(time*2.4)*.13;tailPivot.rotation.y=Math.sin(time*1.8)*.09;
300 const blink=Math.pow(Math.max(0,Math.sin(time*.57)),44);head.children.forEach(m=>{if(m.material===materials.eyes)m.scale.y=.105*(1-blink*.92);});
301 const positions=scarfGeo.attributes.position;
302 for(let i=0;i<positions.count;i++){const x=scarfBase[i*3],r=(.5-x);positions.setY(i,scarfBase[i*3+1]+Math.sin(r*7-time*8)*.06*r);positions.setZ(i,Math.sin(r*5-time*5)*.07*r);}
303 positions.needsUpdate=true;scarfGeo.computeVertexNormals();oceanUniforms.uTime.value=time;
304 for(const b of boats){b.g.position.y=-.05+Math.sin(time*.8+b.x)*.07;b.g.rotation.x=Math.sin(time*.7+b.x)*.045;b.g.rotation.z=Math.sin(time*.6+b.x)*.025;b.g.position.x=b.x+Math.sin(time*.03)*2;}
305 for(const b of birds){b.g.position.x=b.x+Math.sin(time*.12+b.phase)*5;b.g.position.y=b.y+Math.sin(time*.6+b.phase)*.3;b.left.rotation.x=Math.sin(time*5+b.phase)*.45;b.right.rotation.x=-Math.sin(time*5+b.phase)*.45;}
306 for(const c of clouds)c.g.position.x=c.x+Math.sin(time*.02)*3;
307 if(cameraTransition){const k=1-Math.exp(-dt*4);camera.position.lerp(cameraGoal.position,k);controls.target.lerp(cameraGoal.target,k);if(camera.position.distanceTo(cameraGoal.position)<.015)cameraTransition=0;}
308 controls.update();
309 sunsetMix=THREE.MathUtils.damp(sunsetMix,sunsetTarget,2.5,dt);
310 skyUniforms.uTop.value.copy(day.top).lerp(dusk.top,sunsetMix);skyUniforms.uHorizon.value.copy(day.horizon).lerp(dusk.horizon,sunsetMix);
311 skyUniforms.uSun.value.set(-.45,.44-sunsetMix*.32,-.75).normalize();
312 scene.fog.color.copy(day.fog).lerp(dusk.fog,sunsetMix);sun.color.copy(day.sun).lerp(dusk.sun,sunsetMix);sun.intensity=3.6-sunsetMix*1.3;
313 sun.position.y=17-sunsetMix*11;ambient.color.copy(day.ambient).lerp(dusk.ambient,sunsetMix);ambient.intensity=2.25-sunsetMix*.5;
314 oceanUniforms.uDeep.value.copy(day.deep).lerp(dusk.deep,sunsetMix);oceanUniforms.uShallow.value.copy(day.shallow).lerp(dusk.shallow,sunsetMix);
315 renderer.render(scene,camera);
316 if(++frames===2){$('loading').classList.add('hidden');setTimeout(()=>$('loading').style.display='none',650);}
317 if(frames%20===0)$('distance').textContent=distance.toFixed(2);
318 }
319 renderer.setAnimationLoop(render);
320 document.addEventListener('visibilitychange',()=>{previous=performance.now();});
321 // Browser agents can use the same controls when WebMCP is available.
322 const state=()=>({ready:frames>1,paused,speed,camera:view,sunset:!!sunsetTarget,elapsedSeconds:Number(time.toFixed(3)),distanceKm:Number(distance.toFixed(4))});
323 const context=document.modelContext;
324 if(context?.registerTool){
325 const lifecycle=new AbortController();
326 const tools=[
327 {name:'inspect_coastal_ride',title:'查看骑行状态',description:'Read the current coastal cat animation playback, speed, camera, lighting and travel progress.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true,untrustedContentHint:false},execute(input){if(input&&Object.keys(input).length)throw new Error('This tool accepts no input properties.');return state();}},
328 {name:'configure_coastal_ride',title:'调整骑行场景',description:'Set the coastal cat animation playback, riding speed, camera preset or daylight using the same controls as the visible page.',inputSchema:{type:'object',properties:{paused:{type:'boolean'},speed:{type:'number',minimum:.3,maximum:2},camera:{type:'string',enum:['coast','close','follow']},sunset:{type:'boolean'}},additionalProperties:false},annotations:{readOnlyHint:false,untrustedContentHint:false},async execute(input){
329 if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['paused','speed','camera','sunset'].includes(k)))throw new Error('Invalid configuration object.');
330 if('paused'in input&&typeof input.paused!=='boolean')throw new Error('paused must be a boolean.');
331 if('speed'in input&&(!Number.isFinite(input.speed)||input.speed<.3||input.speed>2))throw new Error('speed must be between 0.3 and 2.');
332 if('camera'in input&&!['coast','close','follow'].includes(input.camera))throw new Error('Unknown camera preset.');
333 if('sunset'in input&&typeof input.sunset!=='boolean')throw new Error('sunset must be a boolean.');
334 if('paused'in input){paused=input.paused;updatePlay();}if('speed'in input)setSpeed(input.speed);if('camera'in input)setView(input.camera);if('sunset'in input&&input.sunset!==!!sunsetTarget)$('sunset').click();
335 await new Promise(resolve=>requestAnimationFrame(resolve));return state();
336 }}
337 ];
338 for(const tool of tools){try{Promise.resolve(context.registerTool(tool,{signal:lifecycle.signal})).catch(()=>{});}catch{}}
339 window.addEventListener('pagehide',()=>lifecycle.abort(),{once:true});
340 }
341}