import type { LevelDefinition, GuideLineDefinition } from './LevelDefinition'; import type { StageDefinition } from './StageCatalog'; import type { SpinDefinition } from './Spin'; import type { StampSlot } from './Stamp'; import type { SwipeLaunch } from './SwipeTrajectory'; import type { BearColor } from './BearCrush'; import { ODD_JELLY_LEVELS } from './OddJelly'; /** 레벨 공통 값을 채워 가이드 레벨을 만든다. 칼질 수 = 가이드 수, 목표 조각 = 칼질 + 1 */ function guide(stageId: string, jellyType: LevelDefinition['jellyType'], tool: LevelDefinition['tool'], minAccuracy: number, guideLines: GuideLineDefinition[], intro?: LevelDefinition['intro']): StageDefinition { const strokes = guideLines.length as 1 | 2 | 3; return { stageId, jellyType, mode: 'guide', tool, targetPieceCount: (strokes + 1) as 2 | 3 | 4, maxKnifeStrokes: strokes, minAccuracy, guideLines, ...(intro ? { intro } : {}) }; } /** * 천천히 썰기 레벨의 썬 조각 폭(젤리 단위, Task 11 사용자 결정: 끝에서 얇은 원판). 조각 최소 폭 규칙(스펙 3.1절, pieceThickness ≥ 0.24)을 * 지키는 가장 얇은 쪽으로 정했다. 긴 오이 단면 원판(지름 0.56)의 절반보다 얇다 */ export { SLOW_SLICE_WIDTH } from '../jelly/SlowFood'; import { SLOW_SLICE_WIDTH } from '../jelly/SlowFood'; /** 천천히 썰기 레벨(G 스펙 3.1절): 식칼, 가이드 없음, 항상 통과(minAccuracy 0), 목표 조각 = 칼질 + 1 */ function slicing(stageId: string, jellyType: LevelDefinition['jellyType'], cuts: 3 | 4 | 5, intro?: LevelDefinition['intro']): StageDefinition { return { stageId, jellyType, mode: 'slow', tool: 'knife', targetPieceCount: (cuts + 1) as 4 | 5 | 6, maxKnifeStrokes: cuts, minAccuracy: 0, guideLines: [], ...(intro ? { intro } : {}), // 리듬 시제품: 목표의 최고 속도(약 0.93)보다 빠른 따라잡기를 허용한다. slow: { cuts, firmness: { vmax: 1.6, k: 96, tempo: 4 }, width: SLOW_SLICE_WIDTH, target: true }, }; } /** 휘둘러 베기 레벨(H 스펙 8절): 식칼(배지 아이콘용), 가이드 없음, 목표 = 발사 수(goal 'pieces' 는 4조각), jellyType = 첫 발사 종류 */ function swiping(stageId: string, launches: SwipeLaunch[], par: number, goal: 'all' | 'pieces', intro?: LevelDefinition['intro']): StageDefinition { return { stageId, jellyType: launches[0].type, mode: 'swipe', tool: 'knife', targetPieceCount: goal === 'pieces' ? 4 : (launches.length as 1 | 2 | 3), maxKnifeStrokes: 1, minAccuracy: 0, guideLines: [], ...(intro ? { intro } : {}), swipe: { launches, par, goal }, }; } /** 발사 하나를 줄여 적는다 */ const L = (at: number, from: [number, number], vel: [number, number], spin: number, type: SwipeLaunch['type']): SwipeLaunch => ({ at, from, vel, spin, type }); /** 회전판 가이드 레벨(D 스펙 2.3절): 식칼, 젤리와 가이드가 판과 함께 돈다 */ function spinning(stageId: string, jellyType: LevelDefinition['jellyType'], minAccuracy: number, spin: SpinDefinition, guideLines: GuideLineDefinition[], intro?: LevelDefinition['intro']): StageDefinition { return { ...guide(stageId, jellyType, 'knife', minAccuracy, guideLines, intro), spin }; } /** 조각 다시 자르기 레벨(D 스펙 3.4절): 식칼, 첫 가이드 하나, 칼질 = 1 + angles 수. 다음 가이드는 세션이 실제 조각에서 계산한다 */ function recutting(stageId: string, jellyType: LevelDefinition['jellyType'], minAccuracy: number, first: GuideLineDefinition, angles: number[], intro?: LevelDefinition['intro']): StageDefinition { const strokes = (angles.length + 1) as 2 | 3; return { ...guide(stageId, jellyType, 'knife', minAccuracy, [first], intro), targetPieceCount: (strokes + 1) as 3 | 4, maxKnifeStrokes: strokes, recut: { angles } }; } /** 반 가르기 레벨(가이드 없음, 식칼 1회) */ function halves(stageId: string, jellyType: LevelDefinition['jellyType'], minAccuracy: number, intro?: LevelDefinition['intro']): StageDefinition { return { stageId, jellyType, mode: 'split', tool: 'knife', targetPieceCount: 2, maxKnifeStrokes: 1, minAccuracy, guideLines: [], ...(intro ? { intro } : {}) }; } /** 자리 하나를 적는다: [모양, 중심 x, 중심 z, 크기, 기울기°]. 자리 id는 찍는 순서대로 s1..sN 이 된다 */ type SlotSpec = [StampSlot['shape'], number, number, number, number]; /** 타이밍 찍기: 기존 자리·횟수를 유지하고 일정 속도로 좌우 왕복한다. */ function stamping(stageId: string, jellyType: LevelDefinition['jellyType'], minAccuracy: number, specs: SlotSpec[], intro?: LevelDefinition['intro']): StageDefinition { const n = specs.length as 1 | 2 | 3 | 4; const slots: StampSlot[] = specs.map(([shape, x, y, size, rotDeg], i) => ({ id: `s${i + 1}`, shape, center: [x, y], size, rotDeg })); return { stageId, jellyType, mode: 'stamp', tool: 'cutter', targetPieceCount: n, maxKnifeStrokes: n, minAccuracy, guideLines: [], ...(intro ? { intro } : {}), stamp: { slots, motion: { limit: 0.70, speed: 0.45 } }, }; } /** * 최초 배치의 레벨 1~58 콘텐츠(G 스펙 3.2절 재배치). 1~4 과일 식칼 직선, 5~10 천천히 썰기(G 스펙 3.1절), 11~13 과일 식칼 직선, * 14~21 과일·채소 직선·곡선 섞음(14레벨 첫 메스), 22~28 반 가르기(오목 사물), 29~34 회전판(과일, D 스펙 2.3절), 35~39 조각 다시 자르기(채소, D 스펙 3.4절). * 1~4와 11~21은 모두 다른 젤리(C1 스펙 5절), 22~28은 모두 다른 사물이다(C2 스펙 4절). minAccuracy 는 레벨 진행 스펙 4.2절 표를 따른다. * 40~45 틀 찍기(F1 스펙 3절, 평평한 사물 6종). minAccuracy 는 IoU 기준이라 새 곡선(45 → 58)이고 1~4·11~39와 40~45 안에서 각각 줄지 않는다. * 자리는 스크래치 검증(2026-10-17)을 마친 값이다: 자리–외곽 여유 ≥ 0.053, 자리 간 간격 ≥ 0.12, 자리마다 inset·메시 코어 통과. * 집어 드는 흔들림 pickup 은 계획표의 0.12 에서 0.25 로 키웠다: 0.12 로는 t = 0.02 에 떼는 찍기가 34(56)·35(67)·36(60)·38(58)·39(59)점으로 minAccuracy 를 넘었다(LevelRules '집어 들자마자 떼기'). * 3~4·11~21레벨 가이드는 스크래치 검증(2026-10-15)을 마친 값이다: 직선 끝점은 외곽 교점 + 0.04, 곡선은 guidePaths 가 외곽 바깥 0.04에서 자른다. */ export const STAGES: readonly StageDefinition[] = [ guide('stage-001', 'watermelon', 'knife', 30, [{ id: 'g1', start: [0, -0.815], end: [0, 0.915] }], 'straight'), guide('stage-002', 'orange', 'knife', 30, [{ id: 'g1', start: [-0.158, -0.977], end: [0.253, 0.957] }]), guide('stage-003', 'lemon', 'knife', 35, [ { id: 'g1', start: [-0.3, -0.598], end: [-0.3, 0.598] }, { id: 'g2', start: [0.3, -0.598], end: [0.3, 0.598] }, ]), guide('stage-004', 'kiwi', 'knife', 35, [ { id: 'g1', start: [-0.459, -0.654], end: [-0.164, 0.735] }, { id: 'g2', start: [0.459, -0.654], end: [0.164, 0.735] }, ]), // 5~10: 1.5초 기본 리듬을 따라잡을 수 있는 시제품. 음식·칼질 수는 유지한다. slicing('stage-005', 'longcucumber', 3, 'slow'), slicing('stage-006', 'zucchini', 3), slicing('stage-007', 'mango', 4), slicing('stage-008', 'papaya', 4), slicing('stage-009', 'melon', 5), slicing('stage-010', 'eggplant', 5), guide('stage-011', 'strawberry', 'knife', 35, [ { id: 'g1', start: [-0.25, -0.839], end: [-0.25, 0.777] }, { id: 'g2', start: [0.25, -0.839], end: [0.25, 0.777] }, ]), guide('stage-012', 'apple', 'knife', 40, [ { id: 'g1', start: [-0.45, -0.778], end: [-0.45, 0.752] }, { id: 'g2', start: [0, -0.885], end: [0, 0.885] }, { id: 'g3', start: [0.45, -0.778], end: [0.45, 0.752] }, ]), guide('stage-013', 'dragonfruit', 'knife', 40, [ { id: 'g1', start: [-0.646, -0.538], end: [-0.198, 0.692] }, { id: 'g2', start: [-0.249, -0.685], end: [0.249, 0.685] }, { id: 'g3', start: [0.198, -0.692], end: [0.646, 0.538] }, ]), guide('stage-014', 'tomato', 'scalpel', 40, [{ id: 'g1', start: [-0.1, -1.1], end: [-0.1, 1.1], curve: { c1: [0.35, -0.35], c2: [0.35, 0.35] } }], 'curve'), guide('stage-015', 'cucumber', 'knife', 40, [ { id: 'g1', start: [-0.5, -0.4], end: [-0.5, 0.4] }, { id: 'g2', start: [0, -0.4], end: [0, 0.4] }, { id: 'g3', start: [0.5, -0.4], end: [0.5, 0.4] }, ]), guide('stage-016', 'banana', 'scalpel', 45, [ { id: 'g1', start: [-0.66, -0.6], end: [-0.32, 0.8], curve: { c1: [-0.05, -0.2], c2: [-0.85, 0.3] } }, { id: 'g2', start: [0.66, -0.6], end: [0.32, 0.8], curve: { c1: [0.05, -0.2], c2: [0.85, 0.3] } }, ]), guide('stage-017', 'mushroom', 'scalpel', 45, [ { id: 'g1', start: [-0.65, 0.25], end: [0.65, 0.25], curve: { c1: [-0.2, -0.1], c2: [0.2, -0.1] } }, { id: 'g2', start: [0.5, -1], end: [0.62, 0.1], curve: { c1: [0.75, -0.6], c2: [0.45, -0.3] } }, ]), guide('stage-018', 'carrot', 'knife', 50, [ { id: 'g1', start: [-0.35, -0.408], end: [-0.35, 0.408] }, { id: 'g2', start: [0.3, -0.274], end: [0.3, 0.274] }, ]), guide('stage-019', 'pear', 'scalpel', 50, [ { id: 'g1', start: [-1.1, 0.05], end: [1.1, 0.05], curve: { c1: [-0.4, 0.3], c2: [0.4, 0.3] } }, { id: 'g2', start: [-0.488, -0.3], end: [0.488, -0.3] }, { id: 'g3', start: [-1.1, 0.5], end: [1.1, 0.5], curve: { c1: [-0.4, 0.75], c2: [0.4, 0.75] } }, ]), guide('stage-020', 'pepper', 'knife', 55, [ { id: 'g1', start: [-0.45, -0.815], end: [-0.45, 0.685] }, { id: 'g2', start: [0, -0.815], end: [0, 0.815] }, { id: 'g3', start: [0.45, -0.815], end: [0.45, 0.685] }, ]), guide('stage-021', 'avocado', 'scalpel', 55, [ { id: 'g1', start: [-0.46, -0.95], end: [-0.46, 0.95], curve: { c1: [-0.72, -0.3], c2: [-0.72, 0.3] } }, { id: 'g2', start: [0, -0.95], end: [0, 0.95], curve: { c1: [0.26, -0.3], c2: [-0.26, 0.3] } }, { id: 'g3', start: [0.46, -0.95], end: [0.46, 0.95], curve: { c1: [0.72, -0.3], c2: [0.72, 0.3] } }, ]), // 22~28: 모두 다른 사물(C2 스펙 4절), 반 가르기 함정이 뚜렷한 것부터 halves('stage-022', 'pan', 55, 'split'), halves('stage-023', 'mouse', 55), halves('stage-024', 'hammer', 60), halves('stage-025', 'watergun', 60), halves('stage-026', 'key', 60), halves('stage-027', 'hairdryer', 60), halves('stage-028', 'guitar', 60), // 29~34: 회전판(D 스펙 2.3절). 가이드는 평행 세로선이고 끝점은 외곽 + 0.04다(29·30·33은 2·1·3레벨 가이드를 다시 씀) spinning('stage-029', 'orange', 60, { speed: 30, dir: 1 }, [{ id: 'g1', start: [-0.158, -0.977], end: [0.253, 0.957] }], 'spin'), spinning('stage-030', 'watermelon', 60, { speed: 40, dir: -1 }, [{ id: 'g1', start: [0, -0.815], end: [0, 0.915] }]), spinning('stage-031', 'kiwi', 60, { speed: 45, dir: 1 }, [ { id: 'g1', start: [-0.25, -0.727], end: [-0.25, 0.727] }, { id: 'g2', start: [0.25, -0.727], end: [0.25, 0.727] }, ]), spinning('stage-032', 'apple', 62, { speed: 50, dir: 1, reverseEvery: 3 }, [ { id: 'g1', start: [-0.3, -0.84], end: [-0.3, 0.83] }, { id: 'g2', start: [0.3, -0.84], end: [0.3, 0.83] }, ]), spinning('stage-033', 'lemon', 62, { speed: 50, dir: 1, pulse: 0.6, pulsePeriod: 4 }, [ { id: 'g1', start: [-0.3, -0.598], end: [-0.3, 0.598] }, { id: 'g2', start: [0.3, -0.598], end: [0.3, 0.598] }, ]), spinning('stage-034', 'dragonfruit', 65, { speed: 55, dir: 1, pulse: 0.5, pulsePeriod: 4, reverseEvery: 3.5 }, [ { id: 'g1', start: [-0.45, -0.636], end: [-0.45, 0.636] }, { id: 'g2', start: [0, -0.714], end: [0, 0.714] }, { id: 'g3', start: [0.45, -0.636], end: [0.45, 0.636] }, ]), // 35~39: 조각 다시 자르기(D 스펙 3.4절). 첫 가이드는 가운데에서 조금 비켜 큰 조각(다음 대상)이 분명하고, 끝점은 외곽 + 0.04다. // 비스듬한 첫 가이드는 (x, 0)을 지나 z축에서 25°(38)·30°(39) 기운다. 첫 칼질을 ±0.1·±10° 빗나가도 이후 가이드로 끝까지 잘린다(RecutLevels) recutting('stage-035', 'cucumber', 65, { id: 'g1', start: [0.15, -0.4], end: [0.15, 0.4] }, [90], 'recut'), recutting('stage-036', 'carrot', 65, { id: 'g1', start: [0.15, -0.306], end: [0.15, 0.306] }, [90]), recutting('stage-037', 'tomato', 68, { id: 'g1', start: [0.12, -0.886], end: [0.12, 0.886] }, [90, 90]), recutting('stage-038', 'pepper', 68, { id: 'g1', start: [-0.28, -0.816], end: [0.424, 0.695] }, [60, 60]), recutting('stage-039', 'cucumber', 70, { id: 'g1', start: [-0.081, -0.4], end: [0.381, 0.4] }, [45, 90]), // 40~45: 틀 찍기. 자리 = [모양, 중심, 크기, 기울기°], 왕복 범위 ±0.70·속력 0.45 stamping('stage-040', 'coaster', 45, [['star', 0, 0, 0.5, 0]], 'stamp'), stamping('stage-041', 'biscuit', 48, [['heart', 0, 0, 0.45, 0]]), stamping('stage-042', 'phone', 50, [['moon', -0.45, 0, 0.4, 0], ['moon', 0.45, 0, 0.4, 180]]), stamping('stage-043', 'comic', 52, [['star', -0.38, -0.4, 0.36, 0], ['star', 0.38, -0.4, 0.36, 15], ['star', 0, 0.4, 0.36, -10]]), stamping('stage-044', 'tray', 55, [['heart', -0.36, -0.27, 0.3, -12], ['heart', 0.36, -0.27, 0.3, 12], ['heart', 0, 0.3, 0.3, 0]]), stamping('stage-045', 'encyclopedia', 58, [['star', -0.46, -0.44, 0.36, 0], ['heart', 0.46, -0.44, 0.33, 0], ['moon', -0.46, 0.44, 0.4, 0], ['flower', 0.46, 0.44, 0.36, 0]]), // 휘둘러 베기 46~58(H 스펙 8절). 발사 수치는 SwipeLevels 테스트(체공 1.0초·56~58 은 1.6초)가 지킨다 swiping('stage-046', [{ ...L(0, [-1.4, 0.1], [0, 0], 20, 'watermelon'), hover: { to: [0, 0.1], arriveSec: 1.0 } }], 1, 'all', 'swipe'), swiping('stage-047', [L(0, [-1.35, -0.3], [0.9, 1.5], 40, 'orange')], 1, 'all'), swiping('stage-048', [L(0, [1.35, -0.3], [-0.95, 1.7], -60, 'apple')], 1, 'all'), swiping('stage-049', [L(0, [0, -1.4], [0, 2.5], 50, 'kiwi')], 1, 'all'), swiping('stage-050', [L(0, [-0.3, -1.4], [0.1, 2.4], 40, 'lemon'), L(0.7, [0.3, -1.4], [-0.1, 2.4], -40, 'tomato')], 2, 'all'), swiping('stage-051', [L(0, [-1.35, -0.2], [0.95, 1.5], 50, 'strawberry'), L(0.3, [1.35, -0.2], [-0.95, 1.5], -50, 'dragonfruit')], 2, 'all'), swiping('stage-052', [L(0, [-0.4, -1.4], [0.15, 2.2], 30, 'orange'), L(0, [0.4, -1.4], [-0.15, 2.7], -30, 'pepper')], 1, 'all'), swiping('stage-053', [L(0, [-0.5, -1.4], [0.1, 2.4], 40, 'kiwi'), L(0.6, [0, -1.4], [0, 2.5], -40, 'apple'), L(1.2, [0.5, -1.4], [-0.1, 2.4], 40, 'lemon')], 3, 'all'), swiping('stage-054', [L(0, [-0.4, -1.4], [-0.12, 2.5], 30, 'tomato'), L(0, [0, -1.4], [0, 2.6], -30, 'orange'), L(0, [0.4, -1.4], [0.12, 2.5], 30, 'avocado')], 1, 'all'), swiping('stage-055', [L(0, [-1.35, -0.2], [1.0, 1.4], 50, 'carrot'), L(0.4, [1.35, -0.2], [-1.0, 1.4], -50, 'cucumber'), L(0.8, [0, -1.4], [0, 2.5], 40, 'watermelon')], 2, 'all'), swiping('stage-056', [L(0, [0, -1.4], [0, 2.7], 25, 'watermelon')], 2, 'pieces'), swiping('stage-057', [L(0, [-1.0, -0.3], [0.7, 1.7], 30, 'orange')], 2, 'pieces'), swiping('stage-058', [L(0, [0, -1.4], [0, 2.7], 60, 'apple')], 2, 'pieces'), // 59~60: 연속 벨트에서 모든 곰을 눌러야 한다. ...([ [['red','green','blue','yellow','white','red','blue','green'],[0.45,0.47,0.43,0.48,0.44,0.46,0.45],7.6], [['yellow','red','white','blue','green','yellow','blue','red','green','white'],[0.43,0.47,0.44,0.48,0.43,0.46,0.45,0.47,0.44],7.9], ] as [BearColor[],number[],number][]).map(([colors,intervals,speed],i):StageDefinition=>({ stageId:`stage-${String(59+i).padStart(3,'0')}`,jellyType:'bear',mode:'bearCrush',tool:'knife', targetPieceCount:1,maxKnifeStrokes:1,minAccuracy:0,guideLines:[],bearCrush:{goal:'all',colors,intervals,speed},...(i===0?{intro:'bearCrush' as const}:{}), })), // 64~68: 보호 색은 매 판 2~3마리. 연속 벨트·짧은 타격 주기에 맞춘 혼합 리듬. ...([ ['blue',['red','blue','green','yellow','blue','white','red','green','blue','yellow'],[0.46,0.44,0.49,0.43,0.47,0.45,0.48,0.44,0.47],7.8], ['red',['green','red','yellow','blue','red','white','green','yellow','red','blue','white'],[0.43,0.46,0.42,0.49,0.43,0.47,0.42,0.48,0.44,0.49],8.4], ['green',['yellow','blue','green','red','white','green','yellow','blue','red','green','white','yellow'],[0.42,0.44,0.48,0.42,0.45,0.42,0.47,0.43,0.45,0.42,0.47],8.7], ['yellow',['red','yellow','white','green','blue','yellow','red','white','green','yellow','blue','red','white'],[0.42,0.46,0.42,0.47,0.42,0.44,0.48,0.42,0.45,0.42,0.47,0.43],9.0], ['white',['blue','red','white','green','yellow','white','blue','red','green','white','yellow','blue','red','white'],[0.42,0.43,0.47,0.42,0.44,0.42,0.46,0.42,0.43,0.47,0.42,0.44,0.42],9.3], ] as [BearColor,BearColor[],number[],number][]).map(([protectedColor,colors,intervals,speed],i):StageDefinition=>({ stageId:`stage-${String(64+i).padStart(3,'0')}`,jellyType:'bear',mode:'bearCrush',tool:'knife', targetPieceCount:1,maxKnifeStrokes:1,minAccuracy:0,guideLines:[],bearCrush:{goal:'spare',protectedColor,colors,intervals,speed}, })), ...ODD_JELLY_LEVELS.map((oddJelly,i):StageDefinition=>({ stageId:`stage-${String(69+i).padStart(3,'0')}`,jellyType:'bear',mode:'oddJelly',tool:'knife',targetPieceCount:1,maxKnifeStrokes:1,minAccuracy:0,guideLines:[],oddJelly, ...(i===0?{intro:'oddJelly' as const}:{}), })), ];