import { pointInPolygon } from '../geometry/Polygon'; import { rotateY, type Vec2 } from '../geometry/Vec2'; import type { StageResult } from './StageSession'; import type { Stars } from './Stars'; import { ODD_JELLY, type OddJellyDefinition } from './OddJelly'; export interface OddJellySnapshot { elapsed: number; remaining: number; found: number; target: number; mistakes: number; stars: number; outcome: 'playing' | 'clear' | 'mistakes' | 'timeout'; selected: number[]; activeId: number | null; pressAge: number; } /** 원래 외곽을 바꾸지 않고 개체 위치·회전만 되돌려 탭을 판정한다. */ export function oddJellyHit(point: Vec2, bodies: readonly {id:number;position:Vec2;rotation:number;outline:readonly Vec2[];selected:boolean}[]): number | null { for(const b of bodies) { if(b.selected)continue; const local=rotateY({x:point.x-b.position.x,y:point.y-b.position.y},-b.rotation); if(pointInPolygon(b.outline,local))return b.id; } return null; } /** 시간과 선택은 탭 시점에 확정하고, 압착은 끝난 판에서도 마지막 한 번을 마친다. */ export class OddJellySession { private elapsed=0; private found=0; private mistakes=0; private outcome:OddJellySnapshot['outcome']='playing'; private readonly selected=new Set(); private activeId:number|null=null; private pressAge:number=ODD_JELLY.cycle; readonly target:number; constructor(readonly definition:OddJellyDefinition) { const bodies=definition.bodies; this.target=bodies.filter(b=>b.variant!=='normal').length; if(!this.target||this.target>=bodies.length||new Set(bodies.map(b=>b.id)).size!==bodies.length|| bodies.some(b=>!Number.isInteger(b.id)||!Number.isFinite(b.position.x)||!Number.isFinite(b.position.y)||!Number.isFinite(b.rotation)||!Number.isFinite(b.strength)||b.strength<0||b.strength>1)) { throw new Error('다른 젤리 찾기 구성이 유효하지 않음'); } } advance(dt:number):void { if(!Number.isFinite(dt)||dt<0)throw new Error('게임 시간은 유한한 양수여야 함'); this.pressAge=Math.min(ODD_JELLY.cycle,this.pressAge+dt); if(this.outcome!=='playing')return; this.elapsed=Math.min(ODD_JELLY.limit,this.elapsed+dt); if(this.elapsed>=ODD_JELLY.limit-1e-9){this.elapsed=ODD_JELLY.limit;this.outcome='timeout';} } select(id:number):boolean { if(this.outcome!=='playing'||this.pressAgeb.id===id); if(!b)return false; this.selected.add(id);this.activeId=id;this.pressAge=0; if(b.variant==='normal')this.mistakes++; else this.found++; if(this.mistakes===3)this.outcome='mistakes'; else if(this.found===this.target)this.outcome='clear'; return true; } snapshot():OddJellySnapshot { return {elapsed:this.elapsed,remaining:ODD_JELLY.limit-this.elapsed,found:this.found,target:this.target,mistakes:this.mistakes, stars:3-this.mistakes,outcome:this.outcome,selected:[...this.selected],activeId:this.activeId,pressAge:this.pressAge}; } getResult():StageResult { const isClear=this.outcome==='clear'; const accuracy=isClear?100:0; return {accuracy,displayAccuracy:String(accuracy),stars:(isClear?3-this.mistakes:0) as Stars,isClear,pieceCount:this.found,targetPieceCount:this.target,guideScores:{}}; } }