import type { StageResult } from './StageSession'; import { fillBodyLimit, fillHeight, fillInterval, fillStars, FILL_SPILL_DURATION, JELLY_FILL, type JellyFillDefinition } from './JellyFill'; import { fillKindAt, type FillKind } from './JellyFillKinds'; import type { Stars } from './Stars'; export type FillOutcome = 'clear' | 'under' | 'over' | 'missed'; export interface FillBody { id: number; born: number; landed: boolean; spilled: boolean; portion: number; kind: FillKind } export interface JellyFillSnapshot { time: number; holding: boolean; phase: 'idle' | 'filling' | 'paused' | 'settling' | 'done'; emitted: number; landed: number; amount: number; landedAmount: number; height: number; targetHeight: number; outcome: FillOutcome | null; stars: Stars; finishedAt: number | null; bodies: FillBody[]; timeLeft: number | null; flow: number; spilled: number; } const EPS = 1e-9; /** 방출과 착지는 같은 게임 시각을 사용해 렌더링 간격에 의존하지 않는다. */ export class JellyFillSession { private time = 0; private holding = false; private closed = false; private started = false; private holdStarted = 0; private next = Infinity; private remaining = 0; private amount = 0; private readonly bodies: FillBody[] = []; private outcome: FillOutcome | null = null; private finishedAt: number | null = null; readonly definition: Readonly; constructor(definition: JellyFillDefinition) { if (!Number.isInteger(definition.target) || definition.target < 1 || definition.target > 60 || !Number.isFinite(definition.interval) || definition.interval <= 0 || !Number.isFinite(definition.fall) || definition.fall <= 0 || (definition.fillWindow !== undefined && (!Number.isFinite(definition.fillWindow) || definition.fillWindow <= 0))) { throw new Error('젤리 담기 정의가 유효하지 않음'); } const portions = definition.portions ?? [1]; // 완전한 반복마다 기본 개수와 같은 부피가 되게 해 모든 목표에 해제 가능한 지점을 보장한다. if (!portions.length || portions.length > 12 || definition.target % portions.length !== 0 || portions.some(p => !Number.isFinite(p) || p < 0.25 || p > 2.5) || Math.abs(portions.reduce((sum,p) => sum+p,0) - portions.length) > EPS) throw new Error('혼합 젤리 크기 패턴이 유효하지 않음'); const kinds=definition.kinds; if(kinds && (kinds.length!==portions.length || kinds.some(k=>k!=='bear'&&k!=='cube')))throw new Error('혼합 젤리 모양 순서가 유효하지 않음'); this.definition = Object.freeze({ ...definition, fillWindow: definition.fillWindow ?? JELLY_FILL.fillWindow, portions: Object.freeze([...portions]), ...(kinds?{kinds:Object.freeze([...kinds])}:{}) }); } press(): void { if (this.holding || this.closed || this.outcome) return; this.holding = true; this.holdStarted = this.time; if (!this.started) { this.started = true; this.emit(this.time); this.remaining = fillInterval(this.definition, 0); } this.next = this.time + this.remaining; } release(): void { this.cancel(); } cancel(): void { if (!this.holding) return; this.remaining = Math.max(0, this.next - this.time); this.holding = false; this.next = Infinity; } /** includeBoundary=false는 해제와 같은 시각의 방출을 제외한다. 착지는 포함한다. */ advance(dt: number, includeBoundary = true): void { if (!Number.isFinite(dt) || dt < 0 || this.outcome) return; const end = this.time + dt; const deadline = this.definition.fillWindow ?? Infinity, cutoff = Math.min(end, deadline); const expires = !this.closed && end >= deadline - EPS; while (this.holding && (includeBoundary && !expires ? this.next <= cutoff + EPS : this.next < cutoff - EPS)) { const born = this.next; this.emit(born); this.next = this.bodies.length < fillBodyLimit(this.definition) ? born + fillInterval(this.definition, born - this.holdStarted) : Infinity; } if (expires && !this.closed) { this.closed = true; this.holding = false; this.next = Infinity; } this.time = end; for (const b of this.bodies) b.landed = b.born + this.definition.fall <= end + EPS; this.resolve(); } private emit(born: number): void { const id=this.bodies.length, pattern=this.definition.portions!, portion=pattern[id % pattern.length]; this.amount += portion; this.bodies.push({ id, born, landed: false, spilled: this.amount > this.definition.target + EPS, portion, kind: fillKindAt(this.definition,id) }); } private resolve(): void { if (!this.closed || this.outcome || !this.bodies.every(b => b.landed && (!b.spilled || b.born + this.definition.fall + FILL_SPILL_DURATION <= this.time + EPS))) return; this.outcome = fillStars(this.bodies.length, this.definition.target) > 0 ? 'clear' : this.amount < this.definition.target ? 'missed' : 'over'; this.finishedAt = this.time; } snapshot(): JellyFillSnapshot { const landed = this.bodies.filter(b => b.landed).length; const landedAmount = this.bodies.reduce((sum,b) => sum + (b.landed ? b.portion : 0),0); return { time: this.time, holding: this.holding, phase: this.outcome ? 'done' : this.closed ? 'settling' : this.holding ? 'filling' : this.started ? 'paused' : 'idle', emitted: this.bodies.length, landed, amount: this.amount, landedAmount, height: fillHeight(Math.min(landedAmount, this.definition.target), this.definition.target, this.definition.portions, this.definition.kinds, this.definition.containerShape), targetHeight: fillHeight(this.definition.target, this.definition.target, this.definition.portions, this.definition.kinds, this.definition.containerShape), outcome: this.outcome, stars: this.outcome === 'clear' ? fillStars(this.bodies.length, this.definition.target) : 0, finishedAt: this.finishedAt, bodies: this.bodies.map(b => ({ ...b })), timeLeft: Math.max(0, this.definition.fillWindow! - this.time), flow: this.holding ? 0.2 + 0.8 * Math.min(1, Math.max(0, this.time - this.holdStarted) / 0.9) : 0, spilled: this.bodies.filter(b => b.spilled && b.landed).length }; } getResult(): StageResult { const s = this.snapshot(), clear = this.outcome === 'clear'; const accuracy = Math.max(0, 100 * (1 - Math.abs(s.landedAmount - this.definition.target) / this.definition.target)); return { accuracy, displayAccuracy: `${Math.floor(accuracy)}`, stars: s.stars, isClear: clear, pieceCount: s.landed, targetPieceCount: this.definition.target, guideScores: {} }; } }