import * as THREE from './vendor/three.module.min.js'; const $=s=>document.querySelector(s), scene=new THREE.Scene(); scene.background=new THREE.Color('#b9ded4'); scene.fog=new THREE.Fog('#b9ded4',16,38); const camera=new THREE.PerspectiveCamera(38,1,.1,100), renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); renderer.setPixelRatio(Math.min(devicePixelRatio,2)); renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap; renderer.outputColorSpace=THREE.SRGBColorSpace; $('#scene').appendChild(renderer.domElement); const hemi=new THREE.HemisphereLight(0xfff5df,0x7aa79b,2.1);scene.add(hemi);const sun=new THREE.DirectionalLight(0xffe5bd,2.4);sun.position.set(-3,8,6);sun.castShadow=true;sun.shadow.mapSize.set(1024,1024);sun.shadow.camera.left=-8;sun.shadow.camera.right=8;sun.shadow.camera.top=8;sun.shadow.camera.bottom=-8;scene.add(sun); const mat=(color,extra={})=>new THREE.MeshStandardMaterial({color,roughness:.78,...extra}); const cream=mat('#f5edcf'), white=mat('#fff8e5'), wing=mat('#e5ddc4'), orange=mat('#e48d4d'), beak=mat('#d97b42'), black=mat('#263c37'), tire=mat('#29433d'), metal=mat('#e5dfc7',{metalness:.45,roughness:.4}), coral=mat('#d96d50'), teal=mat('#36796d'), sand=mat('#d7c6a1'), water=mat('#8fc6bc'), grass=mat('#86aa8e'), pink=mat('#f2c7aa'); function mesh(geo,m,x=0,y=0,z=0,parent=scene){const o=new THREE.Mesh(geo,m);o.position.set(x,y,z);o.castShadow=true;o.receiveShadow=true;parent.add(o);return o} function ball(m,x,y,z,sx,sy,sz,parent=scene){const o=mesh(new THREE.SphereGeometry(1,24,16),m,x,y,z,parent);o.scale.set(sx,sy,sz);return o} function rod(a,b,r,m,parent=scene){let va=new THREE.Vector3(...a),vb=new THREE.Vector3(...b),d=vb.clone().sub(va);let o=mesh(new THREE.CylinderGeometry(r,r,d.length(),8),m,0,0,0,parent);o.position.copy(va.add(vb).multiplyScalar(.5));o.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),d.normalize());return o} function updateRod(o,a,b){let va=new THREE.Vector3(...a),vb=new THREE.Vector3(...b),d=vb.clone().sub(va);o.position.copy(va.add(vb).multiplyScalar(.5));o.scale.y=d.length();o.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),d.normalize())} function unitRod(m,r,parent){return mesh(new THREE.CylinderGeometry(r,r,1,8),m,0,0,0,parent)} const ground=mesh(new THREE.PlaneGeometry(100,100),sand,0,-.04,0);ground.rotation.x=-Math.PI/2;ground.receiveShadow=true; const sea=mesh(new THREE.PlaneGeometry(100,30),water,0,-.025,-18);sea.rotation.x=-Math.PI/2; const verge=mesh(new THREE.PlaneGeometry(100,2),grass,0,-.015,-3.4);verge.rotation.x=-Math.PI/2; // A handmade low-poly bicycle, viewed from its right-hand side. const bike=new THREE.Group();scene.add(bike);const wheelCenters=[[-1.15,.68,0],[1.15,.68,0]], wheels=[]; for(const [x,y,z] of wheelCenters){const g=new THREE.Group();g.position.set(x,y,z);bike.add(g);mesh(new THREE.TorusGeometry(.62,.065,10,48),tire,0,0,0,g);mesh(new THREE.TorusGeometry(.53,.018,8,48),metal,0,0,.015,g);for(let i=0;i<12;i++){let a=i*Math.PI/6;rod([0,0,.02],[Math.cos(a)*.52,Math.sin(a)*.52,.02],.008,metal,g)}ball(teal,0,0,.02,.08,.08,.08,g);wheels.push(g)} const tube=(a,b,r=.035,m=teal)=>rod(a,b,r,m,bike); tube([-1.15,.68,0],[-.42,1.58,0]);tube([-.42,1.58,0],[.05,.69,0]);tube([.05,.69,0],[-1.15,.68,0]);tube([-.42,1.58,0],[.65,1.48,0]);tube([.65,1.48,0],[.05,.69,0]);tube([.65,1.48,0],[1.15,.68,0]);tube([.65,1.48,0],[.55,1.76,0],.025,metal);tube([.55,1.76,0],[.8,1.83,0],.035,black);tube([-.42,1.58,0],[-.5,1.79,0],.03,metal);ball(black,-.54,1.8,0,.23,.055,.12,bike);ball(metal,.05,.69,.09,.13,.13,.08,bike); // Basket and little parcel. const basket=mesh(new THREE.BoxGeometry(.55,.32,.38),coral,.98,1.62,0,bike);basket.rotation.z=-.08;mesh(new THREE.BoxGeometry(.38,.16,.29),cream,1,1.83,0,bike);tube([.81,1.75,.21],[1.18,1.75,.21],.012,metal); const bird=new THREE.Group();bird.position.set(-.48,1.7,.02);bike.add(bird); ball(cream,0,.57,0,.56,.65,.42,bird);ball(wing,-.18,.57,.37,.37,.39,.09,bird);ball(white,.22,1.1,0,.24,.55,.23,bird);ball(white,.35,1.57,0,.34,.34,.30,bird); // Long, unmistakable pelican bill and soft pouch. let bill=mesh(new THREE.ConeGeometry(.18,1.03,4),beak,.87,1.49,.015,bird);bill.rotation.z=-Math.PI/2;bill.rotation.y=Math.PI/4;ball(orange,.73,1.36,0,.49,.15,.19,bird);ball(black,.48,1.64,.276,.045,.05,.025,bird);ball(white,.49,1.655,.298,.013,.014,.008,bird); // Tail feathers, scarf and fluttering ends. for(let i=0;i<3;i++){const f=ball(cream,-.49-i*.08,.48+i*.09,0,.30,.09,.12,bird);f.rotation.z=.25+i*.15} let scarf=mesh(new THREE.TorusGeometry(.23,.055,8,24),coral,.24,1.18,0,bird);scarf.rotation.x=Math.PI/2;const scarfTail=mesh(new THREE.BoxGeometry(.45,.11,.035),coral,-.05,1.2,.25,bird);scarfTail.rotation.z=.2; const crank=new THREE.Group();crank.position.set(.05,.69,.16);bike.add(crank);tube([.05,.69,.16],[.05,.69,.16],.02,metal);const crankArm=rod([-.23,0,0],[.23,0,0],.025,metal,crank);ball(black,-.23,0,.05,.12,.035,.08,crank);ball(black,.23,0,.05,.12,.035,.08,crank); const legs=[unitRod(orange,.035,bike),unitRod(orange,.035,bike),unitRod(orange,.035,bike),unitRod(orange,.035,bike)]; // Repeating roadside details travel past the stationary rider. const scenery=new THREE.Group();scene.add(scenery);const props=[]; function makeProp(x,kind){let g=new THREE.Group();g.position.set(x,0,-2.6-Math.random()*.5);scenery.add(g);if(kind===0){rod([0,0,0],[0,.8,0],.045,teal,g);ball(white,0,.87,0,.13,.13,.13,g);ball(coral,0,.98,0,.16,.04,.16,g)}else if(kind===1){ball(grass,0,.13,0,.45,.18,.24,g);for(let i=0;i<3;i++){let xx=(i-1)*.22;rod([xx,.1,0],[xx,.45+i*.05,0],.012,teal,g);ball(i===1?pink:cream,xx,.48+i*.05,0,.075,.075,.075,g)}}else{mesh(new THREE.BoxGeometry(.7,.12,.2),white,0,.35,0,g);rod([-.27,0,0],[-.27,.35,0],.025,metal,g);rod([.27,0,0],[.27,.35,0],.025,metal,g)}props.push(g)} for(let i=0;i<14;i++)makeProp(i*2.6-17,i%3); for(let i=0;i<9;i++){let cloud=new THREE.Group();cloud.position.set(i*5-20,5+(i%3)*.5,-12);scene.add(cloud);ball(white,0,0,0,.8,.23,.2,cloud);ball(white,.35,.12,0,.4,.28,.2,cloud);ball(white,-.35,.1,0,.45,.25,.2,cloud)} const sunDisc=mesh(new THREE.SphereGeometry(.75,24,16),mat('#f8df9d',{emissive:'#c8a25b',emissiveIntensity:.3}),-5,5,-14);sunDisc.castShadow=false; let speed=12,playing=true,night=false,dist=0,phase=0,last=performance.now(),angle=.22,targetAngle=.22,drag=false,px=0,toastTimer;const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; function resize(){let w=innerWidth,h=innerHeight;renderer.setSize(w,h);camera.aspect=w/h;camera.fov=w<600?46:38;camera.updateProjectionMatrix()}addEventListener('resize',resize);resize(); function notify(t){$('#toast').textContent=t;$('#toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('#toast').classList.remove('show'),1500)} function setSpeed(v){speed=Math.max(4,Math.min(24,v));$('#speed').textContent=speed;notify(speed===4?'悠闲骑行':speed===24?'追上风啦':`速度 ${speed} km/h`)} function toggle(){playing=!playing;$('#play-icon').textContent=playing?'Ⅱ':'▶';$('#play-label').textContent=playing?'暂停':'继续';$('#toggle').setAttribute('aria-label',playing?'暂停骑行':'继续骑行')} function sky(){night=!night;scene.background.set(night?'#456b77':'#b9ded4');scene.fog.color.copy(scene.background);hemi.intensity=night?1.1:2.1;sun.intensity=night?.8:2.4;sunDisc.material.color.set(night?'#f4edcc':'#f8df9d');$('#sky').textContent=night?'☾':'☼';$('#weather').textContent=night?'夜色':'晴朗';notify(night?'夜骑时间':'日光正好')} let audio;function bell(){try{audio ||=new AudioContext();if(audio.state==='suspended')audio.resume();let t=audio.currentTime;[0,.12].forEach((offset)=>{let osc=audio.createOscillator(),gain=audio.createGain();osc.type='sine';osc.frequency.setValueAtTime(1250,t+offset);gain.gain.setValueAtTime(.0001,t+offset);gain.gain.exponentialRampToValueAtTime(.15,t+offset+.01);gain.gain.exponentialRampToValueAtTime(.0001,t+offset+.45);osc.connect(gain).connect(audio.destination);osc.start(t+offset);osc.stop(t+offset+.46)})}catch{}notify('叮铃,借过一下!')} $('#slower').onclick=()=>setSpeed(speed-2);$('#faster').onclick=()=>setSpeed(speed+2);$('#toggle').onclick=toggle;$('#bell').onclick=bell;$('#sky').onclick=sky;$('#help').onclick=()=>$('#help-panel').hidden=false;$('#close-help').onclick=$('#got-it').onclick=()=>$('#help-panel').hidden=true; addEventListener('keydown',e=>{if(e.target.tagName==='BUTTON'&&e.code==='Space')return;if(e.code==='Space'){e.preventDefault();toggle()}else if(e.key==='ArrowLeft'){e.preventDefault();setSpeed(speed-2)}else if(e.key==='ArrowRight'){e.preventDefault();setSpeed(speed+2)}else if(e.key.toLowerCase()==='b')bell();else if(e.key.toLowerCase()==='d')sky();else if(e.key==='Escape')$('#help-panel').hidden=true}); renderer.domElement.addEventListener('pointerdown',e=>{drag=true;px=e.clientX;renderer.domElement.setPointerCapture(e.pointerId);$('#hint').style.opacity='0'});renderer.domElement.addEventListener('pointermove',e=>{if(drag){targetAngle=Math.max(-.6,Math.min(.8,targetAngle+(e.clientX-px)*.006));px=e.clientX}});renderer.domElement.addEventListener('pointerup',()=>drag=false);renderer.domElement.addEventListener('pointercancel',()=>drag=false); function tick(now){requestAnimationFrame(tick);let dt=Math.min((now-last)/1000,.05);last=now;let movement=playing?speed/12:0;phase+=dt*movement*(reduced?1.4:3.2);if(playing){dist+=speed*dt/3600;$('#distance').textContent=dist.toFixed(2)}wheels.forEach(w=>w.rotation.z=-phase*1.5);crank.rotation.z=-phase;const p1=[.05+Math.cos(phase)*.23,.69-Math.sin(phase)*.23,.23],p2=[.05-Math.cos(phase)*.23,.69+Math.sin(phase)*.23,-.1];let hips=[[-.45,2.12,.3],[-.45,2.12,-.1]];[p1,p2].forEach((p,i)=>{let h=hips[i],k=[(h[0]+p[0])/2-.16,(h[1]+p[1])/2,.1+i*.05];updateRod(legs[i*2],h,k);updateRod(legs[i*2+1],k,p)});bike.position.y=playing&&!reduced?Math.sin(phase*2)*.018:0;bird.rotation.z=playing&&!reduced?Math.sin(phase)*.018:0;scarfTail.rotation.z=.2+(playing&&!reduced?Math.sin(phase*2)*.15:0);props.forEach(p=>{p.position.x-=dt*movement*1.6;if(p.position.x< -18)p.position.x+=36.4});angle+=(targetAngle-angle)*.06;let narrow=innerWidth<600;let radius=narrow?8.9:8;camera.position.set(Math.sin(angle)*radius,3.1,Math.cos(angle)*radius);camera.lookAt(0,narrow?1.65:1.5,0);renderer.render(scene,camera)}requestAnimationFrame(tick);