import { validateDestructionDefinition, type DestructionDefinition } from '../stages/Destruction'; export interface Point3 {x:number;y:number;z:number} export interface VolumeChange {removed:number;points:Point3[];detached:number[][]} const emptyChange=():VolumeChange=>({removed:0,points:[],detached:[]}); function validatePoint(p:Point3):void { if(![p.x,p.y,p.z].every(Number.isFinite))throw new Error('파괴 좌표는 유한해야 함'); } /** 표시 표면과 접촉 판정이 함께 소비하는 영구 재료 상태. */ export class DestructionVolume { readonly resolution=32; readonly span=2.6; readonly origin:Readonly={x:-1.3,y:-0.35,z:-1.3}; readonly field=new Float32Array(32**3); private readonly damage=new Float64Array(32**3); readonly initialCount:number; remainingCount=0; version=0; constructor(def:DestructionDefinition) { validateDestructionDefinition(def); const step=this.span/this.resolution; if(def.width>this.span-step*2||def.depth>this.span-step*2||def.height>this.origin.y+this.span-step*2-0.03) { throw new Error('파괴 재료가 격자의 표면 여백을 벗어남'); } for(let i=0;i=0&&y<=1&&(def.shape==='block' ?Math.abs(x)**8+Math.abs(z)**8<=1 :def.shape==='oval'?x*x+z*z<=1:x*x+y*y+z*z<=1); if(inside){this.field[i]=1;this.remainingCount++;} } this.initialCount=this.remainingCount; if(this.initialCount===0)throw new Error('파괴 재료가 격자 해상도보다 작음'); } point(index:number):Point3 { if(!Number.isInteger(index)||index<0||index>=this.field.length)throw new Error('격자 인덱스가 유효하지 않음'); const n=this.resolution,s=this.span/n; return {x:this.origin.x+(index%n)*s,y:this.origin.y+(Math.floor(index/n)%n)*s,z:this.origin.z+Math.floor(index/(n*n))*s}; } carve(from:Point3,to:Point3,radius:number,damage:number):VolumeChange { return this.carvePath([from,to],radius,damage); } /** 한 시간 스텝의 경로 합집합에 닿은 각 재료에 손상을 한 번만 누적한다. */ carvePath(points:readonly Point3[],radius:number,damage:number):VolumeChange { return this.carvePaths([points],radius,damage); } /** 비접촉 구간으로 나뉜 경로도 한 스텝의 손상을 중복 누적하지 않는다. */ carvePaths(paths:readonly (readonly Point3[])[],radius:number,damage:number):VolumeChange { const points=paths.flat(); for(const p of points)validatePoint(p); if(!Number.isFinite(radius)||radius<=0||!Number.isFinite(damage)||damage<0)throw new Error('파괴 반경과 손상이 유효하지 않음'); const result=emptyChange(); if(damage===0||points.length===0)return result; let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity; for(const p of points) { minX=Math.min(minX,p.x);minY=Math.min(minY,p.y);minZ=Math.min(minZ,p.z); maxX=Math.max(maxX,p.x);maxY=Math.max(maxY,p.y);maxZ=Math.max(maxZ,p.z); } const segments=paths.flatMap(path=>Array.from({length:path.length?Math.max(1,path.length-1):0},(_,i)=>{ const from=path[i],to=path[Math.min(i+1,path.length-1)]; const dx=to.x-from.x,dy=to.y-from.y,dz=to.z-from.z; return {from,dx,dy,dz,len2:dx*dx+dy*dy+dz*dz}; })); const r2=radius*radius; const n=this.resolution,step=this.span/n; const lower=(v:number,o:number)=>Math.max(0,Math.ceil((v-radius-o)/step)); const upper=(v:number,o:number)=>Math.min(n-1,Math.floor((v+radius-o)/step)); const x0=lower(minX,this.origin.x),x1=upper(maxX,this.origin.x); const y0=lower(minY,this.origin.y),y1=upper(maxY,this.origin.y); const z0=lower(minZ,this.origin.z),z1=upper(maxZ,this.origin.z); for(let z=z0;z<=z1;z++)for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++) { const i=x+y*n+z*n*n;if(this.field[i]===0)continue; const p=this.point(i); const touched=segments.some(({from,dx,dy,dz,len2})=>{ const t=len2===0?0:Math.max(0,Math.min(1,((p.x-from.x)*dx+(p.y-from.y)*dy+(p.z-from.z)*dz)/len2)); return (p.x-from.x-t*dx)**2+(p.y-from.y-t*dy)**2+(p.z-from.z-t*dz)**2<=r2; }); if(!touched)continue; this.damage[i]+=damage; if(this.damage[i]<1-1e-10)continue; this.field[i]=0;result.points.push(p);result.removed++; } if(result.removed>0) { this.remainingCount-=result.removed;this.version++; const detached=this.detach(); result.removed+=detached.removed;result.points.push(...detached.points);result.detached=detached.detached; } return result; } /** 여섯 이웃으로 연결된 최대 성분만 본체에 남긴다. */ detach():VolumeChange { const result=emptyChange(); if(this.remainingCount===0)return result; const n=this.resolution,n2=n*n,visited=new Uint8Array(this.field.length),components:number[][]=[]; for(let i=0;i0?current-1:-1,x0?current-n:-1,y0?current-n2:-1,z=0&&this.field[next]&&!visited[next]){visited[next]=1;component.push(next);} } components.push(component); } let largest=0; for(let i=1;icomponents[largest].length)largest=i; for(let c=0;c0){this.remainingCount-=result.removed;this.version++;} return result; } topAt(x:number,z:number):number|null { if(!Number.isFinite(x)||!Number.isFinite(z))throw new Error('표면 좌표는 유한해야 함'); const n=this.resolution,step=this.span/n; const ix=Math.round((x-this.origin.x)/step),iz=Math.round((z-this.origin.z)/step); if(ix<0||ix>=n||iz<0||iz>=n)return null; for(let y=n-1;y>=0;y--)if(this.field[ix+y*n+iz*n*n])return this.origin.y+y*step; return null; } get remainingRatio():number {return this.remainingCount/this.initialCount;} }