import { formatAccuracy } from '../cutting/AccuracyCalculator'; import type { CutResolution, PieceData } from '../cutting/CutResolver'; import { polygonCentroid } from '../geometry/Polygon'; import { add, dist, len, normalize, sub, v2, type Vec2 } from '../geometry/Vec2'; import { getJellyShape, type JellyType } from '../jelly/JellyShapes'; import type { LevelDefinition } from './LevelDefinition'; import type { StageResult } from './StageSession'; import type { Crossing } from './SwipeHit'; import type { CutLine } from '../cutting/StrokeLine'; import { separationVelocities } from './SwipeCut'; import { swipeStars } from './SwipeStars'; import { HIT_MARGIN, RECUT_ASSIST, SWIPE_GRAVITY } from './SwipeTuning'; import { ballisticPose, launchPose, localToWorld, playBox, rotY, type Ballistic, type PlayBox, type Pose, type SwipeLaunch, } from './SwipeTrajectory'; const DEG = Math.PI / 180; /** 목표 조각 수(goal 'pieces') */ const PIECES_GOAL = 4; export type BodyMotion = { kind: 'launch'; launch: SwipeLaunch } | { kind: 'free'; ballistic: Ballistic }; export interface SwipeBody { id: number; type: JellyType; /** 원본 젤리 좌표 그대로의 조각(offset 은 항상 0) */ data: PieceData; /** `data.polygon` 의 넓이 중심(원본 젤리 좌표) */ centroid: Vec2; /** 외곽 최대 폭 */ diameter: number; /** 넓이 중심에서 외곽까지 최대 거리 + `HIT_MARGIN` */ radius: number; /** 몇 번째 자손인가(발사체 0) */ gen: number; motion: BodyMotion; /** 이 몸체의 뿌리 발사 번호 */ launchIndex: number; /** 이 몸체를 만든 휘두르기 id. 발사체는 null */ bornSwipe: number | null; /** flying: 날고 있음, pending: 벨 결과를 기다림, cut: 벴음(더는 없음), fallen: 상자 아래로 떨어짐 */ state: 'flying' | 'pending' | 'cut' | 'fallen'; cuttable: boolean; } export type SwipeStatus = 'playing' | 'clear' | 'fail'; const maxRadius = (poly: readonly Vec2[], c: Vec2): number => poly.reduce((m, p) => Math.max(m, dist(p, c)), 0); const maxDiameter = (poly: readonly Vec2[]): number => { let d = 0; for (let i = 0; i < poly.length; i++) for (let j = i + 1; j < poly.length; j++) d = Math.max(d, dist(poly[i], poly[j])); return d; }; /** * 휘둘러 베기 한 판의 규칙 상태(H 스펙 3절). 렌더링과 무관한 순수 로직이다. * 시간 t 는 호출자가 정하는 비행 시계(초)다. 궤적은 닫힌 식이라 프레임 속도와 무관하다. */ export class SwipeSession { readonly level: LevelDefinition; box: PlayBox = playBox(390 / 844); private readonly launches: readonly SwipeLaunch[]; private readonly goal: 'all' | 'pieces'; private readonly tutorial: boolean; private readonly spawned: boolean[]; private readonly _bodies: SwipeBody[] = []; private readonly slashed = new Set(); private nextId = 1; private _status: SwipeStatus = 'playing'; constructor(level: LevelDefinition) { if (!level.swipe) throw new Error(`레벨 ${level.id} 는 휘둘러 베기 레벨이 아니다`); this.level = level; this.launches = level.swipe.launches; this.goal = level.swipe.goal; this.tutorial = this.launches.some((l) => l.hover); this.spawned = this.launches.map(() => false); } get bodies(): readonly SwipeBody[] { return this._bodies; } get status(): SwipeStatus { return this._status; } get swipes(): number { return this.slashed.size; } /** 벤 원본(발사체) 수 */ get cutCount(): number { return this._bodies.filter((b) => b.bornSwipe === null && b.state === 'cut').length; } /** 잎 조각 수: 자손 가운데 아직 벤 적 없는 것(떨어진 조각 포함) */ get pieceCount(): number { return this.goal === 'pieces' ? this._bodies.filter((b) => b.bornSwipe !== null && b.state !== 'cut').length : 0; } setBox(b: PlayBox): void { this.box = b; } pose(b: SwipeBody, t: number): Pose { if (b.motion.kind === 'free') return ballisticPose(b.motion.ballistic, t); // 발사 시각 전에는 만들어지지 않으므로 null 이 아니다. 방어로 시작 자세를 돌려준다. return launchPose(b.motion.launch, t, this.box) ?? launchPose(b.motion.launch, b.motion.launch.at, this.box)!; } /** 발사 시각이 된 몸체를 만들고 떨어진 몸체를 처리한 뒤 끝 판정을 한다. */ update(t: number): { launched: SwipeBody[]; fallen: SwipeBody[] } { const launched: SwipeBody[] = []; const fallen: SwipeBody[] = []; // 판이 끝나면(클리어·실패) 남은 발사체는 더 날리지 않고 떨어짐만 처리한다 this.launches.forEach((l, i) => { if (this.spawned[i] || l.at > t || this._status !== 'playing') return; this.spawned[i] = true; const b = this.makeBody(l.type, this.wholePiece(l.type), 0, { kind: 'launch', launch: l }, i, null, this._status === 'playing'); this._bodies.push(b); launched.push(b); }); for (const b of this._bodies) { if (b.state !== 'flying') continue; const p = this.pose(b, t); if (p.vel.y > 0 && p.pos.y - b.radius > this.box.halfH) { b.state = 'fallen'; fallen.push(b); } } this.evaluate(); return { launched, fallen }; } /** 지금 이 휘두르기로 벨 수 있는 몸체인가 */ hittable(b: SwipeBody, _swipeId: number): boolean { return this._status === 'playing' && b.state === 'flying' && b.cuttable; } /** 같은 id 는 한 번만 센다. 젤리를 맞히지 못한 휘두르기도 센다. */ markSlash(swipeId: number): void { this.slashed.add(swipeId); } /** 몸체를 pending으로 두고 실제 국소 진입–이탈을 잇는 직선을 돌려준다. */ planCut(bodyId: number, c: Crossing, _swipeId: number): { line: CutLine } { const b = this.body(bodyId); b.state = 'pending'; return { line: { origin: c.entry, dir: normalize(sub(c.exit, c.entry)), tMin: -b.diameter, tMax: b.diameter } }; } /** * 절단 결과를 확정한다. null이거나 `cut`이 아니면 원래 몸체를 다음 베기가 가능한 상태로 돌린다. * 자식은 부모 자세에서 이어 받은 월드 탄도로 갈라진다. */ applyCut(bodyId: number, cut: CutResolution | null, t: number, dirWorld: Vec2, swipeId: number): SwipeBody[] { const parent = this.body(bodyId); if (!cut || cut.kind !== 'cut' || cut.pieces.length < 2) { parent.cuttable = this._status === 'playing'; parent.state = 'flying'; this.evaluate(); return []; } const pose = this.pose(parent, t); const spin = parent.motion.kind === 'launch' ? parent.motion.launch.spin * DEG : parent.motion.ballistic.spin; const parts = cut.pieces; const childC = parts.map((p) => polygonCentroid(p.polygon)); const childWorld = childC.map((c) => localToWorld(c, parent.centroid, pose)); // 절단 직선의 월드 방향. 휘두른 방향이 퇴화했으면 이 방향을 대신 쓴다. const lineDir = this.lineDirWorld(cut, pose); const swipeDir = len(dirWorld) > 1e-9 ? dirWorld : lineDir; const vels = separationVelocities(pose.vel, lineDir, childWorld, pose.pos, swipeDir); const g = SWIPE_GRAVITY * this.box.halfH; const assist = this.goal === 'pieces' ? RECUT_ASSIST * this.box.halfH : 0; parent.state = 'cut'; parent.cuttable = false; const kids = parts.map((p, i) => { const v0 = add(vels[i], v2(0, -assist)); const data: PieceData = { ...p, id: this.nextId, offset: v2(0, 0) }; const ballistic: Ballistic = { t0: t, p0: childWorld[i], v0, g, angle0: pose.angle, spin }; const kid = this.makeBody(parent.type, data, parent.gen + 1, { kind: 'free', ballistic }, parent.launchIndex, swipeId, this.goal === 'pieces' && this._status === 'playing'); this._bodies.push(kid); return kid; }); this.evaluate(); return kids; } getResult(): StageResult { const clear = this._status === 'clear'; const swipes = this.level.swipe!; const stars = clear ? swipeStars(this.swipes, swipes.par) : 0; const accuracy = clear ? 100 : 0; return { accuracy, displayAccuracy: formatAccuracy(accuracy), pieceCount: this.goal === 'all' ? this.cutCount : this.pieceCount, targetPieceCount: this.level.targetPieceCount, stars, isClear: stars > 0, guideScores: {}, }; } private body(id: number): SwipeBody { const b = this._bodies.find((x) => x.id === id); if (!b) throw new Error(`몸체 ${id} 가 없다`); return b; } private wholePiece(type: JellyType): PieceData { const outline = getJellyShape(type).outline; return { id: this.nextId, polygon: [...outline], cutEdges: outline.map(() => false), offset: v2(0, 0) }; } private makeBody(type: JellyType, data: PieceData, gen: number, motion: BodyMotion, launchIndex: number, bornSwipe: number | null, cuttable: boolean): SwipeBody { const centroid = polygonCentroid(data.polygon); return { id: this.nextId++, type, data, centroid, diameter: maxDiameter(data.polygon), radius: maxRadius(data.polygon, centroid) + HIT_MARGIN, gen, motion, launchIndex, bornSwipe, state: 'flying', cuttable, }; } /** 실제 절단 직선(국소 방향)을 부모 자세로 월드로 돌린 방향. 결과에 직선이 없으면 두 자식 중심을 잇는 선에 수직으로 둔다. */ private lineDirWorld(cut: CutResolution, pose: Pose): Vec2 { const d = cut.line?.dir; if (d && len(d) > 1e-9) return rotY(normalize(d), pose.angle); const [a, b] = cut.pieces.map((p) => polygonCentroid(p.polygon)); const n = normalize(sub(b, a)); return rotY(v2(-n.y, n.x), pose.angle); } /** 끝 판정. 끝나면 모든 몸체를 벨 수 없게 한다. */ private evaluate(): void { if (this._status !== 'playing') return; const rootsAll = this._bodies.filter((b) => b.bornSwipe === null); const allSpawned = this.spawned.every(Boolean); if (this.goal === 'all') { if (allSpawned && rootsAll.every((b) => b.state === 'cut')) return this.finish('clear'); if (!this.tutorial && rootsAll.some((b) => b.state === 'fallen')) return this.finish('fail'); return; } const pieces = this.pieceCount; if (pieces >= PIECES_GOAL) return this.finish('clear'); if (this.tutorial) return; const alive = this._bodies.some((b) => (b.state === 'flying' || b.state === 'pending') && b.cuttable); if (!alive && allSpawned) this.finish('fail'); } private finish(status: 'clear' | 'fail'): void { this._status = status; for (const b of this._bodies) b.cuttable = false; } }