import { Vec2, v2 } from '../geometry/Vec2'; import { signedDistance } from '../geometry/Polygon'; import { CutLine, resamplePath } from '../cutting/StrokeLine'; import type { GuidePath } from '../cutting/AccuracyCalculator'; import { getJellyShape } from '../jelly/JellyShapes'; import type { LevelDefinition } from './LevelDefinition'; import { STAGES } from './StageDefinitions'; import { orderStages } from './StageCatalog'; import stageOrder from './stage-order.json' with { type: 'json' }; export { SLOW_SLICE_WIDTH } from '../jelly/SlowFood'; export const LEVELS: readonly LevelDefinition[] = orderStages(STAGES, stageOrder); export function getLevel(id: number): LevelDefinition | undefined { return LEVELS.find((l) => l.id === id); } /** 가이드가 외곽 바깥으로 이어지는 거리 */ const GUIDE_MARGIN = 0.04; const toV = (p: readonly [number, number]) => v2(p[0], p[1]); function bezierPoints(p0: Vec2, c1: Vec2, c2: Vec2, p3: Vec2, n: number): Vec2[] { return Array.from({ length: n }, (_, i) => { const t = i / (n - 1); const u = 1 - t; return v2( u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x, u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y, ); }); } /** 촘촘한 곡선 표본을 젤리 안쪽 구간 앞뒤로 외곽 바깥 GUIDE_MARGIN 지점까지 남기고 자른다. */ function trimToOutline(samples: Vec2[], outline: readonly Vec2[]): Vec2[] { const sd = samples.map((p) => signedDistance(outline, p)); const first = sd.findIndex((d) => d < 0); if (first < 0) throw new Error('곡선 가이드가 젤리를 지나지 않는다'); let last = sd.length - 1; while (sd[last] >= 0) last--; let i = first; while (i > 0 && sd[i] < GUIDE_MARGIN) i--; let j = last; while (j < sd.length - 1 && sd[j] < GUIDE_MARGIN) j++; const cutAt = (outside: number, inside: number) => { const t = (sd[outside] - GUIDE_MARGIN) / (sd[outside] - sd[inside]); const a = samples[outside]; const b = samples[inside]; return v2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); }; const start = sd[i] >= GUIDE_MARGIN ? cutAt(i, i + 1) : samples[i]; const end = sd[j] >= GUIDE_MARGIN ? cutAt(j, j - 1) : samples[j]; return [start, ...samples.slice(i + 1, j), end]; } /** 레벨 가이드를 젤리 좌표 꺾은선으로 돌려준다. 직선 가이드는 점 2개, 곡선 가이드는 64점이다. */ export function guidePaths(level: LevelDefinition): GuidePath[] { const outline = getJellyShape(level.jellyType).outline; return level.guideLines.map((g) => { if (!g.curve) return { id: g.id, points: [toV(g.start), toV(g.end)] }; const dense = bezierPoints(toV(g.start), toV(g.curve.c1), toV(g.curve.c2), toV(g.end), 400); return { id: g.id, points: resamplePath(trimToOutline(dense, outline), 64) }; }); } /** 천천히 썰기 직선이 외곽 밖(위아래)으로 이어지는 거리 */ const SLOW_LINE_MARGIN = 0.3; /** * 천천히 썰기 레벨의 썰 직선(원본 젤리 좌표, 썰 순서 = 왼쪽부터). 외곽 x 최소에서 폭 slow.width 씩 떼어 내는 x = 상수 직선이고(남은 덩어리가 더 크다), * 방향은 (0, 1), 양 끝은 외곽 y 범위 밖으로 SLOW_LINE_MARGIN 만큼 이어진다. slow 레벨이 아니면 빈 배열이다. */ export function slowCutLines(level: LevelDefinition): CutLine[] { if (!level.slow) return []; const outline = getJellyShape(level.jellyType).outline; const xs = outline.map((p) => p.x); const ys = outline.map((p) => p.y); const x0 = Math.min(...xs); const [y0, y1] = [Math.min(...ys), Math.max(...ys)]; const w = level.slow.width; return Array.from({ length: level.slow.cuts }, (_, i) => ({ origin: v2(x0 + w * (i + 1), 0), dir: v2(0, 1), tMin: y0 - SLOW_LINE_MARGIN, tMax: y1 + SLOW_LINE_MARGIN, })); }