import {Box3,Quaternion,Vector3} from 'three'; import type {Vec2} from '../../geometry/Vec2'; import type {Solid} from './EndlessSolid'; export const ENDLESS_FALL={floor:.012,gravity:11.5,step:1/120,restitution:.17,friction:.52,airDrag:.5,contactDrag:5,sleepDelay:.18,maxTime:2.4} as const; /** 프레임별 자식 준비 때 질량중심·지지점·관성을 계산해 인계 프레임에 몰지 않는다. */ export class EndlessFallShape { readonly center=new Vector3(); readonly points:Vector3[]=[]; readonly size=new Vector3(); readonly normals:Vector3[]=[]; constructor(solid:Solid){ let volume=0;const a=new Vector3(),b=new Vector3(),c=new Vector3(),cross=new Vector3(),sum=new Vector3(); for(const face of solid.faces)for(let i=1;i1e-15)this.center.divideScalar(volume); const seen=new Set(),bounds=new Box3(); for(const face of solid.faces)for(const {p}of face.vertices){ const key=[p.x,p.y,p.z].map(v=>v.toFixed(8)).join(',');if(seen.has(key))continue;seen.add(key); const point=new Vector3(p.x,p.y,p.z).sub(this.center);this.points.push(point);bounds.expandByPoint(point); } bounds.getSize(this.size); const faces=solid.faces.map(face=>{ const normal=new Vector3();for(let i=0;if.area>1e-10).sort((a,b)=>b.area-a.area); for(const face of faces){if(this.normals.some(n=>n.dot(face.normal)>.9999))continue;this.normals.push(face.normal);if(this.normals.length===8)break;} } } /** 한 조각의 중력·도마 접촉·마찰. 논리 형상은 그대로 두고 위치/회전만 갱신한다. */ export class EndlessFall { readonly position=new Vector3(); readonly rotation=new Quaternion(); readonly velocity=new Vector3(); readonly bounds=new Box3(); readonly shape:EndlessFallShape; sleeping=false; clearance=0; private center=new Vector3(); private omega=new Vector3(); private invInertia=new Vector3(); private inverseRotation=new Quaternion(); private delta=new Quaternion(); private worldPoints:Vector3[]; private r=new Vector3(); private contactVelocity=new Vector3(); private torque=new Vector3(); private response=new Vector3(); private scratch=new Vector3(); private offset=new Vector3(); private accumulator=0; private elapsed=0; private quietTime=0; private hasLanded=false; constructor(source:Solid|EndlessFallShape,position:Vector3,rotation:Quaternion,private readonly scale:number,outward:Vec2,reducedMotion=false){ this.shape=source instanceof EndlessFallShape?source:new EndlessFallShape(source);this.rotation.copy(rotation);this.position.copy(position); this.center.copy(this.shape.center).multiplyScalar(scale).applyQuaternion(rotation).add(position);this.worldPoints=this.shape.points.map(()=>new Vector3()); const size=this.shape.size.clone().multiplyScalar(scale),min=Math.max(size.x,size.y,size.z)*.001; size.x=Math.max(min,size.x);size.y=Math.max(min,size.y);size.z=Math.max(min,size.z); // 조각 사이 질량 교환은 없으므로 단위 질량으로 중력과 도마 반력을 계산한다. this.invInertia.set(12/(size.y**2+size.z**2),12/(size.x**2+size.z**2),12/(size.x**2+size.y**2)); const strength=Math.sqrt(Math.max(.001,Math.min(1.4,scale))),slender=Math.max(0,Math.min(3,size.y/Math.max(min,Math.min(size.x,size.z))-1)); this.velocity.set(outward.x*.32*strength,1.05*strength,outward.y*.32*strength); this.omega.set(outward.y,0,-outward.x).multiplyScalar(.85+slender*.65);this.refreshBounds(); if(reducedMotion)this.settleInstantly(); } private refreshBounds():void { this.bounds.makeEmpty();for(let i=0;i2.7?2.7-this.bounds.max.x:0; const dz=this.bounds.min.z< -2.35?-2.35-this.bounds.min.z:this.bounds.max.z>2.35?2.35-this.bounds.max.z:0; if(dx||dz){this.scratch.set(dx,0,dz);this.center.add(this.scratch);this.bounds.translate(this.scratch);if(dx)this.velocity.x*= -.12;if(dz)this.velocity.z*= -.12;} } private settleInstantly():void { const current=this.rotation.clone(),candidate=new Quaternion(),turn=new Quaternion(),down=new Vector3(0,-1,0);let best=Infinity; const bestRotation=current.clone(); for(const normal of this.shape.normals){ turn.setFromUnitVectors(this.scratch.copy(normal).applyQuaternion(current),down);candidate.copy(turn).multiply(current); let minY=Infinity;for(const point of this.shape.points)minY=Math.min(minY,this.scratch.copy(point).multiplyScalar(this.scale).applyQuaternion(candidate).y); const height=-minY,score=height+this.shape.size.length()*this.scale*.002*candidate.angleTo(current); if(score=.5){const justLanded=!this.hasLanded;this.hasLanded=true;this.settleInstantly();return {justLanded,impact:0};} this.accumulator+=dt;let justLanded=false,impact=0; const up=new Vector3(0,1,0); while(this.accumulator+1e-10>=ENDLESS_FALL.step&&!this.sleeping){ const step=ENDLESS_FALL.step;this.accumulator=Math.max(0,this.accumulator-step);this.elapsed+=step; this.velocity.y-=ENDLESS_FALL.gravity*step;this.velocity.multiplyScalar(Math.exp(-ENDLESS_FALL.airDrag*step));this.center.addScaledVector(this.velocity,step); const speed=this.omega.length();if(speed>1e-8){this.delta.setFromAxisAngle(this.scratch.copy(this.omega).divideScalar(speed),Math.min(12,speed)*step);this.rotation.premultiply(this.delta).normalize();} this.refreshBounds(); const touching=this.clearance<=.0005; if(touching){ let count=0;this.r.set(0,0,0);const margin=Math.max(.00005,this.shape.size.length()*this.scale*.0015); for(const p of this.worldPoints)if(p.y<=this.bounds.min.y+margin){this.r.add(p);count++;} this.r.divideScalar(Math.max(1,count)).sub(this.center); if(this.clearance<0)this.center.y-=this.clearance; this.inverseRotation.copy(this.rotation).invert();this.contactVelocity.crossVectors(this.omega,this.r).add(this.velocity); const incoming=this.contactVelocity.y,normalImpulse=incoming<0?-(1+(incoming< -.22?ENDLESS_FALL.restitution:0))*incoming/this.denominator(up):0; if(normalImpulse>0){ this.impulse(up,normalImpulse);impact=Math.max(impact,-incoming); if(!this.hasLanded){this.hasLanded=true;justLanded=true;} this.contactVelocity.crossVectors(this.omega,this.r).add(this.velocity);this.contactVelocity.y=0; const tangentSpeed=this.contactVelocity.length(); if(tangentSpeed>1e-7){this.contactVelocity.divideScalar(tangentSpeed);const amount=Math.min(tangentSpeed/this.denominator(this.contactVelocity),ENDLESS_FALL.friction*normalImpulse);this.impulse(this.contactVelocity,-amount);} } this.omega.multiplyScalar(Math.exp(-(this.elapsed>ENDLESS_FALL.maxTime?30:ENDLESS_FALL.contactDrag)*step)); if(this.elapsed>ENDLESS_FALL.maxTime){this.velocity.x*=Math.exp(-20*step);this.velocity.z*=Math.exp(-20*step);} } this.refreshBounds();this.clampBoard();this.refreshBounds(); if(touching&&this.velocity.lengthSq()<.003&&this.omega.lengthSq()<.015)this.quietTime+=step;else this.quietTime=0; // 지지점이 번갈아 닿는 극소 진동은 quietTime을 반복 초기화할 수 있다. // 충분히 감쇠한 뒤 다음 도마 접촉에서 현재 자세로 쉬어 갱신 누적을 막는다. if(this.quietTime>=ENDLESS_FALL.sleepDelay||(touching&&this.elapsed>=ENDLESS_FALL.maxTime+1.2)){ // 실제로 안정된 자세를 그대로 보존한다. 정지 순간 다른 면으로 돌려 고정하지 않는다. this.center.y-=this.clearance;this.refreshBounds();this.velocity.set(0,0,0);this.omega.set(0,0,0);this.sleeping=true; } } return {justLanded,impact}; } }