import type { Vec2 } from '../geometry/Vec2'; import { formatAccuracy } from '../cutting/AccuracyCalculator'; import type { StageResult } from './StageSession'; import { CRUSH, crushScore, type CrushContact } from './Crush'; import { BEAR_CRUSH, type BearColor, type BearCrushDefinition } from './BearCrush'; export type BearOutcome = 'pending'|'safe'|'crushed'|'mistake'; export interface BearJellyState { id:number; color:BearColor; arrival:number; x:number; score:number|null; contact:CrushContact|null; impactAge:number; outcome:BearOutcome; } export interface BearCrushSnapshot { clock:number; phase:'idle'|'down'|'contact'|'up'; strikeTime:number; strikes:number; mistakes:number; done:boolean; jellies:BearJellyState[]; } export interface BearImpact { kind:'contact'|'missed'|'empty'; id:number|null; color:BearColor|null; score:number; contact:CrushContact|null; mistake:boolean } const CYCLE=BEAR_CRUSH.down+BEAR_CRUSH.hold+BEAR_CRUSH.up; /** 이동 시계는 타격 중에도 멈추지 않는다. 입력과 접촉 사이 위치를 실제 시간으로 적분한다. */ export class BearCrushSession { private clock=0; private strikeTime=CYCLE; private pending=false; private strikes=0; private mistakes=0; private readonly jellies:BearJellyState[]; private readonly minX:number; constructor(private readonly outline:readonly Vec2[],readonly definition:BearCrushDefinition) { const {colors,intervals,speed}=definition; const xs=outline.map(p=>p.x); this.minX=Math.min(...xs); const span=Math.max(...xs)-this.minX+2*CRUSH.halfWidth; if(outline.length<3||colors.length<3||intervals.length!==colors.length-1|| (definition.goal==='spare'&&(!colors.includes(definition.protectedColor)||colors.filter(c=>c===definition.protectedColor).length<2||colors.every(c=>c===definition.protectedColor)))|| !Number.isFinite(speed)||speed<=0|| intervals.some(t=>!Number.isFinite(t)||t*speed<=span)) throw new Error('곰 젤리 구성 또는 등장 간격이 유효하지 않음'); let arrival=BEAR_CRUSH.firstArrival; this.jellies=colors.map((color,id)=>{ if(id>0)arrival+=intervals[id-1]; return {id,color,arrival,x:-arrival*speed,score:null,contact:null,impactAge:0,outcome:'pending'}; }); } get done():boolean {return this.jellies.every(j=>j.outcome!=='pending')&&this.strikeTime>=CYCLE-1e-9;} press():boolean { if(this.strikeTimeCRUSH.halfWidth) { j.score=0; j.outcome=this.isProtected(j.color)?'safe':'mistake'; if(j.outcome==='mistake') { this.mistakes++; events.push({kind:'missed',id:j.id,color:j.color,score:0,contact:null,mistake:true}); } } } return events; } private contact():BearImpact { this.pending=false; for(const j of this.jellies) { if(j.outcome!=='pending')continue; const hit=crushScore(this.outline,j.x); if(hit.score<=1e-8)continue; j.score=hit.score;j.contact=hit.contact;j.impactAge=0; const mistake=this.isProtected(j.color)||hit.score=CYCLE-1e-9?'idle':t({...j,contact:j.contact?{...j.contact}:null}))}; } private isProtected(color:BearColor):boolean { return this.definition.goal==='spare'&&color===this.definition.protectedColor; } getResult():StageResult { const others=this.jellies.filter(j=>!this.isProtected(j.color)); const accuracy=others.reduce((sum,j)=>sum+(j.score??0),0)/others.length; const stars=this.mistakes?0:accuracy>=BEAR_CRUSH.threeStars?3:accuracy>=BEAR_CRUSH.twoStars?2:1; return {accuracy,displayAccuracy:formatAccuracy(accuracy),stars,isClear:stars>0, pieceCount:others.filter(j=>j.outcome==='crushed').length,targetPieceCount:others.length, guideScores:Object.fromEntries(this.jellies.map(j=>[String(j.id),j.score??0]))}; } }