# 힐링 모드 파트 2 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **판 번호 변경(2026-11-02):** 이 계획의 211~213은 병합 시 212~214로 바뀌었다(무한 자르기가 211판을 먼저 차지). 아래 본문은 실행 당시 기록이다. **Goal:** 211~213판에 선 긋기 조준 → Slice mode 당겨 썰기를 쓰는 새 힐링 모드를 추가한다. 별 은하·발바닥 고양이·보석 왕관 세 젤리와 젤리별 단면 효과를 함께 만든다. **Architecture:** - 새 `HealingPart2Mode`(GameMode)와 새 장면 `Healing2Scene`을 `src/core/healing2/`에 둔다. 파트 1 파일은 동작을 바꾸지 않는다. - 다음은 파트 1 모듈을 그대로 import해 쓴다: 착지(`HealingChunkMotion`), 배치(`HealingLayout`), 눌림 uniform(`healingStretch`), 칼 치수·자원(`HealingKnifeProfile`, `createKnife`), 깊이 드래그(`HealingGesture`), 튜토리얼(`HealingTutorial`), 진행 저장(`discoverAndOfferExit`). - 다음은 새로 만든다: 오목 윤곽 형상(순수 모듈), 오목 단면·연결 성분 절단기, 선 긋기 판정, 당겨 썰기 자세(순수 모듈), 셰이더. **Tech Stack:** TypeScript, Three.js(WebGL2), 커스텀 GLSL(`onBeforeCompile`), `cdt2d` 기반 `CapTriangulator`, Vitest, Playwright. **Spec:** `docs/superpowers/specs/2026-11-01-healing-part2-design.md` ## Global Constraints - 파트 1(206~210판)의 조작·외관·동작은 바꾸지 않는다. 파트 1 파일 수정은 `HealingTutorial`의 선택 필드 추가(기존 동작 유지)만 허용한다. - 모드는 `Game`을 import하지 않고 `ModeHost`를 쓴다. `Game`에 모드별 분기를 추가하지 않는다. - `src/geometry/`, `src/stages/`, 그리고 이 계획의 순수 모듈(`Healing2Shape.ts`, `StrokeAim.ts`, `PullCut.ts`)은 three를 import하지 않는다. - 조각 정점은 원본 젤리 좌표를 유지한다. 이동·기울기는 `object` 변환으로만 표현한다. `healingCut` 속성 규약(겉면 0, 단면 1, 잘린 삼각형은 보간)을 지킨다. - 새 조각 메시는 프레임마다 나눠 준비한다. 입력 콜백에서 메시를 만들지 않는다. 준비 자원의 인계·취소·해제 책임을 진다. - 레벨 교체·숨김·컨텍스트 손실 시 장치 입력 취소가 모드 `exit`·`cancelInput`보다 먼저다. 시간은 `GameClock`을 따른다. - 절단음: long은 썰기 중 반복(`host.feel.slicing`), 도마 접촉 시 `boardTap` 한 번. 새 효과음은 없다. - 고유 ID: `healing-star-galaxy`, `healing-cat-paws`, `healing-jewel-crown`. 편성은 `stage-order.json` 끝(`healing-crystal-scales` 뒤)에만 추가한다. `legacy-stage-order.json`은 수정하지 않는다. - 진행: 첫 단면이 열리는 순간 고정 3별을 한 번 기록한다. 버튼은 `Next Jelly` / `Finish`(213) / `Back`(재방문). - 조준 중 칼 불투명도 35%, 확정 후 불투명. 손잡이는 화면 아래쪽, 거의 수평이면 오른쪽. - 문구는 `src/i18n/texts.csv`(영어 기본, 한국어)에 추가한다. `texts.generated.json`은 직접 편집하지 않는다. - `window.__jellyDebug`에 `healing2()`를 추가하고 `e2e/debug-api.spec.ts`와 `docs/agent-reference/debug-api.md`를 갱신한다. - 시각 검수는 **전체 시각 검수**다. 3판 × 상태 5개 × 390×844·1280×800, 그리고 저품질 모바일 3장. 캡처는 `.playwright-mcp/healing-part2/`에 둔다. 시간에 따라 변하는 반짝임과 실기기 촉감·성능은 사용자 확인 항목이다. 폰트 외관은 이 검수에서 판정하지 않는다(폰트 작업의 사용자 담당). - 최종 독립 리뷰는 **Opus 5.5 / High**(사용자 지정)로 코드와 캡처를 함께 검토한다. - 커밋 메시지는 한국어로 쓰고 끝에 `Co-Authored-By: Claude Opus 5.5 `를 붙인다. ## Review Focus 1. **선을 긋는 도중 포인터 취소·화면 숨김**: 조준이 확정되면 안 되고 선·미리보기가 지워져야 한다. → Task 8 `HealingPart2Mode.test.ts`의 `"긋는 중 cancel·hidden은 확정하지 않는다"`. 2. **두 번째 손가락**: 긋는 동안 다른 손가락이 닿았다 떨어져도 선이 바뀌거나 확정되면 안 된다. 원래 손가락을 뗄 때만 확정한다. → Task 8 `"두 번째 포인터는 무시한다"`. 3. **확정 후 대상이 전부 부스러기로 판명**: 준비 중에 모든 대상이 빠지면 Slice mode에 갇히지 않고 안내와 함께 조준으로 돌아가야 한다. → Task 7 `"모든 대상이 부스러기면 cutRejected"`, Task 8 `"cutRejected면 조준으로 복귀하고 안내한다"`. 4. **썰다가 다시 들어가기·나가기**: 반짝임 중 재시작·메뉴 복귀 후 GPU 자원이 쌓이면 안 된다. → Task 10 E2E `"재진입 시 프로그램·텍스처가 누적되지 않는다"`(`healing-reference.spec.ts`의 같은 검사 방식). 5. **태블릿 회전·크기 변경**: 긋는 도중이나 확정 직후 화면 크기가 바뀌면 화면 좌표 선이 어긋난다. 선을 지우고 조준 상태로 되돌린다. Slice mode에서는 월드 좌표로 고정돼 있으므로 유지한다. → Task 8 `"aim 중 resize는 선을 지운다"`. --- ### Task 1: 세 젤리의 순수 형상 모듈 **Files:** - Modify: `src/stages/LevelDefinition.ts:34` 근처 (`HealingPart2Kind` 추가) - Create: `src/core/healing2/Healing2Shape.ts` - Test: `tests/Healing2Shape.test.ts` **Interfaces:** - Produces: - `export type HealingPart2Kind = 'star-galaxy' | 'cat-paws' | 'jewel-crown';` — `LevelDefinition.ts` - `healing2Outline(kind: HealingPart2Kind): Vec2[]`: 원본 젤리 좌표 (x, z)의 반시계 단순 다각형. 꼭짓점 96~160개. - `healing2Height(kind: HealingPart2Kind, p: Vec2): { bottom: number; top: number }`: `bottom`은 항상 0(도마 위에 놓인다). 윤곽 밖에서는 `top = 0`. - `healing2Inclusions(kind: HealingPart2Kind): readonly Healing2Inclusion[]` - `interface Healing2Inclusion { shape: 'sphere' | 'paw' | 'gem' | 'star' | 'spiral'; center: { x: number; y: number; z: number }; radius: number; color: [number, number, number]; stripe?: [number, number, number] }` - `HEALING2_MAX_TOP: Record` 형상 시작값(시각 검수에서 조정한다): | kind | 윤곽 | 높이 | |---|---|---| | star-galaxy | 오각 별. 바깥 반지름 0.95, 안쪽 0.5, 꼭짓점·오목점 둥글기 0.12 | 평평한 윗면 0.55, 가장자리 0.12 폭에서 둥글게 0.32까지 내려감 | | cat-paws | 몸통 타원(0.62×0.48, 중심 z=+0.18)과 머리 원(r 0.42, 중심 z=−0.42)의 매끈한 합집합 | 몸통 돔 최고 0.78, 머리 돔 최고 0.9, 귀 두 개(머리 위 ±0.24, 반지름 0.12)는 1.18까지 솟음 | | jewel-crown | 원 r 0.85 | 바깥 띠(r>0.72)는 금빛 벽. 72° 간격 뿔 다섯 개는 1.25, 뿔 사이 아치 바닥은 0.82. 안쪽 젤리 윗면은 0.9 돔 | - 내부 장식은 원본 좌표에 고정된 고정 배열이다. 난수를 쓰면 시드를 고정한다. - 별: 행성 구슬 3~4개, 별 5~7개, 소용돌이 1개. 별자리 점은 셰이더 해시로 그린다. - 고양이: 발바닥 7~9개. - 왕관: 보석 4~6개. 진주는 뿔 끝 장식이다. - [ ] **Step 1: 실패하는 테스트 작성** — `tests/Healing2Shape.test.ts` ```ts for (const kind of ['star-galaxy', 'cat-paws', 'jewel-crown'] as const) { test(`${kind} 윤곽은 반시계 단순 다각형`, () => { const o = healing2Outline(kind); expect(polygonArea(o)).toBeGreaterThan(0.8); // 반시계 = 양수 expect(isSimplePolygon(o)).toBe(true); // 변끼리 교차 없음(테스트 내 도우미) for (const p of o) expect(Math.max(Math.abs(p.x), Math.abs(p.y))).toBeLessThanOrEqual(0.96); }); test(`${kind} 높이는 윤곽 안에서 벽 두께 0.25 이상`, () => { for (const p of samplePointsInside(healing2Outline(kind), 400)) { const h = healing2Height(kind, p); expect(h.bottom).toBe(0); expect(h.top).toBeGreaterThanOrEqual(0.25); expect(h.top).toBeLessThanOrEqual(HEALING2_MAX_TOP[kind] + 1e-9); } }); test(`${kind} 내부 장식은 젤리 안에 있다`, () => { for (const s of healing2Inclusions(kind)) { expect(pointInPolygon({ x: s.center.x, y: s.center.z }, healing2Outline(kind))).toBe(true); expect(s.center.y + s.radius).toBeLessThan(healing2Height(kind, { x: s.center.x, y: s.center.z }).top); } }); } test('별 윤곽은 오목하다', () => expect(reflexVertexCount(healing2Outline('star-galaxy'))).toBeGreaterThanOrEqual(5)); ``` - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/Healing2Shape.test.ts` / Expected: FAIL(모듈 없음) - [ ] **Step 3: `Healing2Shape.ts` 구현.** `src/geometry/Polygon`의 기존 함수를 재사용한다. three는 import하지 않는다. - [ ] **Step 4: 통과 확인** — Run: `npx vitest run tests/Healing2Shape.test.ts` / Expected: PASS - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 세 젤리의 순수 형상 추가"` ### Task 2: 젤리 종류 등록과 미리보기(도감·썸네일) **Files:** - Modify: `src/jelly/JellyShapes.ts:7-12, 400-` (JellyType 유니온, `BUILDERS`) - Modify: `src/jelly/JellyMaterial.ts:66` (`FIELD_GLSL`), `src/jelly/JellyPiece.ts:77` (`SPRINGS`) - Modify: `src/ui/JellyThumbnail.ts:5, 50` (`JELLY_NAMES`, `COLORS`) - Modify: `src/i18n/texts.csv` (젤리 이름) - Modify: `tests/meshFuzz.ts:152` (`FUZZ_GROUPS`) - Test: `tests/JellyMesh.test.ts`(기존 합집합 검사), `tests/JellyMeshFuzz.healing.test.ts` **Interfaces:** - Consumes: Task 1 `healing2Outline`. - Produces: JellyType `'healing-star-galaxy' | 'healing-cat-paws' | 'healing-jewel-crown'`. - 이름 문구: `Star Galaxy` / `별 은하`, `Paw Cat` / `발바닥 고양이`, `Jewel Crown` / `보석 왕관`. i18n 키는 기존 `jelly.*` 형식을 따른다. - [ ] **Step 1: 실패하는 테스트** — `tests/meshFuzz.ts`의 `FUZZ_GROUPS.healing`에 세 JellyType을 추가한다. 그러면 `JellyMesh.test.ts`의 합집합 검사와 `JellyMeshFuzz.healing.test.ts`가 새 종류를 요구한다. - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/JellyMesh.test.ts tests/JellyMeshFuzz.healing.test.ts` / Expected: FAIL(타입 없음) - [ ] **Step 3: 구현.** - `BUILDERS`는 `healing2Outline(kind)`를 테이블 메시 외곽으로 쓴다. - `FIELD_GLSL`은 미리보기용 단순 색 필드다. 별: 남색과 하늘색 + 흰 점, 고양이: 장밋빛 + 파스텔 점, 왕관: 투명 + 금색 테두리. - `SPRINGS`는 `healing-galaxy` 값을 복사한다. - [ ] **Step 4: 통과 확인** — Run: `npx vitest run tests/JellyMesh.test.ts tests/JellyMeshFuzz.healing.test.ts && npm run build` / Expected: PASS. 퍼즈 임계값은 바꾸지 않는다. - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 젤리 종류와 도감 미리보기 등록"` ### Task 3: 높이 함수 입체 메시 생성 **Files:** - Create: `src/core/healing2/Healing2Geometry.ts` - Create: `tests/healing2Fixtures.ts` (테스트 도우미) - Test: `tests/Healing2Geometry.test.ts` **Interfaces:** - Consumes: Task 1 `healing2Outline`, `healing2Height`, `triangulatePolygonCap(boundary, spacing)` (`src/geometry/CapTriangulator.ts:12`). - Produces: - `healing2Geometry(kind: HealingPart2Kind): THREE.BufferGeometry` — 닫힌 입체. 속성은 `position`, `normal`, `healingCut`(전부 0). 인덱스 유무는 자유다. 이후 절단기는 둘 다 받는다. - `tests/healing2Fixtures.ts`: `toMeshLike(g: THREE.BufferGeometry): MeshLike`(비인덱스면 인덱스를 만든다), `geometryVolume(g): number`. 구성: - 윗면·아랫면은 같은 `triangulatePolygonCap(outline, 0.05)` 결과를 쓴다. 윗면은 `top`, 아랫면은 `bottom` 높이를 준다. - 옆면은 윤곽 각 변에서 bottom→top을 세로 4칸으로 나눈 사각형이다. - 윗면 법선은 높이 함수의 중앙 차분으로 매끈하게 한다. - [ ] **Step 1: 실패하는 테스트** ```ts for (const kind of KINDS) test(`${kind} 입체는 메시 검사를 통과`, () => { const g = healing2Geometry(kind); const r = validateMesh(toMeshLike(g)); expect(r.closed).toBe(true); expect(r.flippedNormals).toBe(0); expect(r.degenerate).toBe(0); expect(r.minAngleDeg).toBeGreaterThan(1); expect(r.volume).toBeGreaterThan(0); expect(Array.from(g.getAttribute('healingCut').array).every(v => v === 0)).toBe(true); }); test('부피는 높이 함수 적분과 3% 이내로 일치', ...); // 격자 0.01 수치 적분과 비교 ``` - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/Healing2Geometry.test.ts` / Expected: FAIL - [ ] **Step 3: 구현** - [ ] **Step 4: 통과 확인** — Expected: PASS - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 높이 함수 입체 메시 생성"` ### Task 4: 오목 단면·연결 성분 절단기 **Files:** - Create: `src/core/healing2/ConcaveClip.ts` - Test: `tests/Healing2Clip.test.ts`, `tests/Healing2ClipFuzz.test.ts` **Interfaces:** - Consumes: `triangulatePolygonCap`, Task 3 `healing2Geometry`, `toMeshLike`, `geometryVolume`. - Produces: - `clipConcaveSolid(source: THREE.BufferGeometry, plane: THREE.Plane, side: 1 | -1): THREE.BufferGeometry[] | null` - 평면의 `side` 쪽 입체를 연결 성분별로 나눈 닫힌 메시 배열을 돌려준다. - 단면 고리가 다른 고리 안에 들어 있는 구멍 단면이면 `null`을 돌려준다. 이 경우 그 조각은 이번 칼질에서 자를 수 없다. - 출력 속성은 `position`, `normal`, `healingCut`이다. 캡은 1이고, 잘린 삼각형은 보간값이다. - `clipConcaveSolidSteps(source, plane, side): Generator` — 같은 결과를 내는 프레임 분할판. 단계마다 `yield 'clip' | 'loops' | 'cap' | 'components'`. 알고리즘: 1. **삼각형 클리핑**: `clipHealingSolid`와 같다. 2. **고리 추적**: 잘린 삼각형마다 교선 선분 하나를 기록한다. 끝점을 `Math.round(v*1e6)`으로 양자화한 키로 이어 닫힌 고리들을 만든다. 3. **평면 기저 투영과 포함 관계**: 고리를 평면 2D 기저(u, v)로 투영한다. 어떤 고리가 다른 고리 안에 있으면 `null`을 반환한다. 4. **캡**: 고리마다 `triangulatePolygonCap(loop2D, 0.05)`로 채운다. 법선은 평면 바깥 방향이다. 경계 점은 교점과 같은 3D 좌표를 쓴다. 5. **연결 성분**: 양자화한 정점 키를 공유하는 삼각형끼리 union-find로 묶는다. 묶음마다 메시를 하나씩 만든다. - [ ] **Step 1: 실패하는 테스트** — `tests/Healing2Clip.test.ts` ```ts test('상자를 자르면 양쪽 각각 닫힌 성분 하나', ...); // closed, 부피 합 = 원본(1e-6 상대) test('별의 두 팔을 지나는 평면은 한쪽에 성분 둘', () => { const g = healing2Geometry('star-galaxy'); const plane = verticalPlaneThroughTwoArms(); // 테스트 내 고정 평면 const a = clipConcaveSolid(g, plane, 1)!, b = clipConcaveSolid(g, plane, -1)!; expect(a.length + b.length).toBe(3); for (const c of [...a, ...b]) expect(validateMesh(toMeshLike(c)).closed).toBe(true); expect(sum([...a, ...b].map(geometryVolume))).toBeCloseTo(geometryVolume(g), 4); }); test('캡 정점의 healingCut은 1', ...); test('구멍 단면이면 null', ...); // 왕관을 안쪽 돔과 뿔 사이 높이의 수평 평면으로 자른다 test('Steps 판과 동기 판의 결과가 같다', ...); ``` - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/Healing2Clip.test.ts` / Expected: FAIL - [ ] **Step 3: 구현** - [ ] **Step 4: 퍼즈 추가** — `tests/Healing2ClipFuzz.test.ts` - 세 kind에 시드 고정 무작위 평면 200개씩 적용한다. 연직 평면이 70%, 최대 35°로 기운 평면이 30%다. - 기운 평면은 이전 절단의 결과물에 한 번 더 적용한다(재절단). - `null`이 아닌 모든 성분에 대해 `closed`, `volume > 0`, `flippedNormals === 0`, `degenerate === 0`을 확인하고, 부피 합은 원본과 1e-4 상대 오차 안이어야 한다. - `minAngleDeg`는 평면 클리핑의 얇은 삼각형 때문에 이 퍼즈에서는 요구하지 않는다. 파트 1의 `clipHealingSolid`도 같은 성질이고 기존 퍼즈 임계값은 바꾸지 않는다. - [ ] **Step 5: 통과 확인** — Run: `npx vitest run tests/Healing2Clip.test.ts tests/Healing2ClipFuzz.test.ts` / Expected: PASS - [ ] **Step 6: 커밋** — `git commit -m "오목 단면과 연결 성분을 지원하는 힐링 절단기 추가"` ### Task 5: 선 긋기 판정(순수) **Files:** - Create: `src/core/healing2/StrokeAim.ts` - Test: `tests/Healing2StrokeAim.test.ts` **Interfaces:** - Produces: - `STROKE_MIN_PX = 40`, `STROKE_SAMPLE_PX = 6`, `STROKE_MAX_SAMPLES = 64` - `strokeSamples(a: Vec2, b: Vec2): Vec2[]` — 화면 선분을 `STROKE_SAMPLE_PX` 간격으로 나눈다. 양 끝을 포함하고 최대 개수는 `STROKE_MAX_SAMPLES`다. - `crossedIds(hits: readonly (number | null)[]): number[]` - 표본마다 맞은 조각 id 배열을 받아, 선분이 **완전히 가로지른** 조각 id를 등장 순서대로 돌려준다. - 완전히 가로지름: 그 id가 한 번 이상 나오고, 첫 표본과 마지막 표본이 그 id가 아니어야 한다. 즉 선분이 그 조각 밖에서 시작해 밖에서 끝난다. - `handleAtB(a: Vec2, b: Vec2): boolean` — 손잡이를 b 쪽에 둘지 정한다. - 화면에서 거의 수평(`|dy| < tan(15°)·|dx|`)이면 x가 큰 쪽이다. - 그 밖에는 y가 큰 쪽(화면 아래)이다. - [ ] **Step 1: 실패하는 테스트** ```ts test('밖→안→밖이면 대상', () => expect(crossedIds([null, 1, 1, null])).toEqual([1])); test('안에서 끝나면 대상 아님', () => expect(crossedIds([null, 1, 1])).toEqual([])); test('안에서 시작하면 대상 아님', () => expect(crossedIds([1, 1, null])).toEqual([])); test('두 조각을 차례로 지나면 등장 순서', () => expect(crossedIds([null, 2, null, 1, 1, null])).toEqual([2, 1])); test('조각에서 조각으로 바로 넘어가도 양쪽 다 대상', () => expect(crossedIds([null, 2, 1, null])).toEqual([2, 1])); test('오목 틈으로 같은 조각을 두 번 지나도 한 번', () => expect(crossedIds([null, 1, null, 1, null])).toEqual([1])); test('표본 간격과 최대 개수', ...); test('손잡이: 아래쪽 끝, 수평이면 오른쪽', () => { expect(handleAtB({ x: 100, y: 100 }, { x: 140, y: 400 })).toBe(true); expect(handleAtB({ x: 140, y: 400 }, { x: 100, y: 100 })).toBe(false); expect(handleAtB({ x: 300, y: 200 }, { x: 50, y: 210 })).toBe(false); }); ``` - [ ] **Step 2: 실패 확인** → **Step 3: 구현** → **Step 4: 통과 확인** — Run: `npx vitest run tests/Healing2StrokeAim.test.ts` - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 선 긋기 대상 판정 추가"` ### Task 6: 당겨 썰기 자세(순수) **Files:** - Create: `src/core/healing2/PullCut.ts` - Test: `tests/Healing2PullCut.test.ts` **Interfaces:** - Produces: - `interface PullCutPlan { near: number; far: number; bladeLength: number; top: number }` - 절단선 위 좌표 u는 손잡이 쪽에서 칼끝 쪽으로 늘어난다. - `[near, far]`는 대상 조각 구간들의 합집합 범위다. `top`은 대상 중 가장 높은 윗면이다. - `interface PullCutPose { tip: number; tipHeight: number; heelHeight: number; pitch: number }` — `tip`은 칼끝의 u 좌표, 높이는 칼날 선의 높이다. - `PULL_MARGIN = 0.08`, `PULL_TIP_DOWN = 0.55`, `PULL_MAX_PITCH = 0.38`, `PULL_MIN_PITCH = 0.08` - `pullCutPose(plan: PullCutPlan, depth: number): PullCutPose` - `passed(plan: PullCutPlan, depth: number, interval: [number, number]): boolean` — 그 구간의 조각이 이 깊이에서 갈라졌는지. - `progress(plan: PullCutPlan, depth: number, interval: [number, number]): number` — 0..1. 구간 중심에서 칼날이 내려간 비율이며, 틈 미리보기에 쓴다. 정의(시작값): - 기울기: `pitch0 = lerp(PULL_MIN_PITCH, PULL_MAX_PITCH, clamp((far - near) / bladeLength, 0, 1))`. 깊이에 따라 0으로 선형 감소하고, `depth = 1`에서 수평이다. - 칼끝 위치: 시작은 `tip0 = far + PULL_MARGIN`, 끝은 `tip1 = min(tip0, near - PULL_MARGIN + bladeLength)`이다. `tip = lerp(tip0, tip1, smoothstep(PULL_TIP_DOWN, 1, depth))`. - 칼끝 높이: `tipHeight = (top + 0.02) · (1 - smoothstep(0, PULL_TIP_DOWN, depth))`. `PULL_TIP_DOWN` 이후에는 도마에 붙은 채 당겨진다. - 손잡이 쪽 높이: `heelHeight = tipHeight + bladeLength · sin(pitch)`. - 갈라짐: `passed`는 `depth >= 1`이거나, `tipHeight == 0`이면서 `tip <= interval[0]`일 때 참이다. - [ ] **Step 1: 실패하는 테스트** ```ts test('깊이 0에서는 먼 끝 위, 칼끝이 아래로 기운다', ...); // tip == far+margin, tipHeight≈top+0.02, pitch>0 test('깊이 1에서는 수평으로 도마에 닿는다', ...); // tipHeight==0, heelHeight==0, pitch==0 test('칼끝 쪽 조각부터 갈라진다', () => { const plan = { near: 0, far: 2.4, bladeLength: 1.6, top: 0.6 }; const dFar = firstDepth(d => passed(plan, d, [1.9, 2.3])); const dNear = firstDepth(d => passed(plan, d, [0.1, 0.5])); expect(dFar).toBeLessThan(dNear); }); test('칼날보다 긴 선은 손잡이 쪽으로 당겨진다', ...); // tip1 < tip0 test('조각 하나면 기울기가 작다', ...); // pitch(0) ≤ PULL_MIN_PITCH+0.05 test('깊이를 되돌리면 같은 자세', ...); // pose는 depth의 순수 함수 ``` - [ ] **Step 2~4: 실패 확인 → 구현 → 통과** — Run: `npx vitest run tests/Healing2PullCut.test.ts` - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 당겨 썰기 자세 계산 추가"` ### Task 7: `Healing2Scene` — 조각·준비·절단·밀어내기·칼 **Files:** - Create: `src/core/healing2/Healing2Scene.ts` - Create: `src/core/healing2/Healing2Knife.ts` (칼 자세 상태기계) - Test: `tests/Healing2Scene.test.ts` **Interfaces:** - Consumes: - Task 3 `healing2Geometry`, Task 4 `clipConcaveSolidSteps`, Task 6 `pullCutPose`·`passed`·`progress`. - `HealingChunkMotion`, `arrangeHealingPieces`, `spaceFirstHealingPair`, `findHealingPlacement`, `repackHealingPieces`, `healingFootprint`, `healingSolidMeasure`, `healingSplitVolumes`, `healingStretch`, `HEALING_KNIFE_PROFILE`, `createKnife`(파트 1 모듈, 수정 없이 import). - Produces(`Healing2Scene`): - `constructor(parent: THREE.Object3D, kind: HealingPart2Kind, camera: THREE.Camera, material: (kind) => THREE.Material)` — 재질 팩토리는 Task 9가 공급한다. 그 전까지는 `MeshPhysicalMaterial`을 기본값으로 쓴다. - `readonly group: THREE.Group; readonly knife: THREE.Group; readonly boardHalfSize: Vec2; readonly surfaceTop: number` - `chunkObjects(): THREE.Object3D[]` — 레이 판정용. - `chunkIdOf(object: THREE.Object3D): number | null` - `topOf(ids: number[]): number` — 대상 윗면의 평균 높이(월드 y). - `setStroke(s: Healing2Stroke | null): void` — 선분 `a`→`b`를 투영 높이에 점선(`LineDashedMaterial`)으로 그린다. 대상 조각에는 곡면 절단 경로 미리보기를 그리고, 반투명(35%) 칼을 선 중점 위에 띄운다. `null`이면 지운다. - `interface Healing2Stroke { a: Vec2; b: Vec2; height: number; origin: Vec2; dir: Vec2; targets: number[]; handleAtEnd: boolean }` - `a`·`b`·`origin`은 도마 월드 (x, z)이고, `height`는 투영 높이(월드 y)다. - `dir`은 `a`→`b` 단위 벡터다. `handleAtEnd`가 참이면 손잡이가 `+dir` 쪽이다. - `HEALING2_MIN_CHILD_VOLUME` — 파트 1 `HealingScene`의 최소 자식 부피(`HealingScene.ts:68`)와 같은 값으로 이 파일에 상수로 정의한다. 파트 1 파일은 수정하지 않는다. - `confirmStroke(): 'ok' | 'empty' | 'noRoom'` - `healingSplitVolumes`로 양쪽 부피가 최소값 미만인 대상을 뺀다. 남은 대상이 없으면 `'empty'`. - 그다음 `PullCutPlan`을 세우고, 칼날 휩쓸림 다각형(깊이 0과 1의 날 발자국의 볼록 껍질 + 0.04)에 걸리는 비대상 조각의 새 자리를 구한다. 실패하면 `'noRoom'`. - 성공하면 칼을 불투명하게 고정한다. - `setDepth(depth: number): void` - 칼 자세를 반영한다. - 밀린 조각을 `smoothstep(0, 0.5, depth)` 비율로 새 자리로 옮긴다. - 준비가 끝난 대상은 `progress`에 비례해 윗부분 틈(최대 0.012)을 보여 준다. - `readonly preparedReady: boolean; readonly cutRejected: boolean; readonly targetIds: number[]; readonly pushedIds: number[]` - `completeCut(): boolean` — `depth = 1`에서 준비된 모든 대상을 성분들로 교체한다. 갈라진 순서와 무관하게 이번 칼질 전체를 한 번에 확정한다. - `cancelCut(): void` — 준비 자원을 폐기하고, 밀린 조각을 원위치로 되돌리고, 칼을 조준 대기로 돌린다. - `readonly revealed: boolean; readonly detached: boolean; readonly spreadAmount: number; readonly cutCount: number` - `revealAge(id: number): number` — 그 조각의 최신 단면이 열린 뒤 지난 초. 열리지 않았으면 −1. - `describeChunks(): { id: number; volume: number; center: THREE.Vector3; tilt: number; progress: number }[]` - `knifePose(): { x: number; y: number; z: number; pitch: number; yaw: number; roll: number; opacity: number }` - `update(dt: number): void; setLowQuality(low: boolean): void; dispose(): void` 동작 규칙: - **조각 구조**: 파트 1 `Chunk`처럼 원본 좌표 정점 + `object` 변환을 쓴다. 누적 절단 평면은 원본 좌표 `THREE.Plane[]`로 보존한다. - **준비** - `setDepth`가 처음 0보다 커진 다음 `update`부터 시작한다. 프레임마다 `clipConcaveSolidSteps` 생성기를 한 번씩 진행한다(대상 × 양쪽). - 결과가 `null`이거나 어떤 성분의 부피가 `HEALING2_MIN_CHILD_VOLUME` 미만이면 그 대상을 빼고 비대상으로 돌린다. 이 조각이 칼날 경로에 있으면 다음 `update`에서 밀어낼 자리를 다시 계산한다. - 대상이 0개가 되거나 자리 재계산에 실패하면 `cutRejected`가 된다. - **완료 후 벌림·착지**: 파트 1과 같다. `revealTime` 0→1.55, 0.42에서 칼을 놓는다. 조각마다 `HealingChunkMotion`을 붙인다. 첫 칼질이 정확히 두 조각일 때만 `spaceFirstHealingPair`를 쓰고, 그 밖에는 `arrangeHealingPieces`만 쓴다. - **자원**: 교체된 원본과 취소된 준비 메시·재질은 파트 1의 `retiredChunks` 방식(2프레임 뒤 해제)으로 dispose한다. - **칼(`Healing2Knife`)** - 상태는 `hover → locked → cutting → lifting → hover`다. - 조준 중에는 선 중점 위 `surfaceTop + 0.18`에 떠서 지수 감쇠로 따라온다(`1 - exp(-dt·16)`). - `cutting`에서는 `pullCutPose`를 월드 자세로 바꾼다. 손잡이 방향은 `handleAtEnd`에 따른다. - 완료 후에는 들어 올려 대기 위치로 돌아간다. - [ ] **Step 1: 실패하는 테스트** — `tests/Healing2Scene.test.ts`. `createKnife`는 `vi.mock`으로 빈 Group을 쓴다(파트 1 테스트 관례). ```ts test('조준·확정 중에는 절단기를 부르지 않는다', ...); // vi.spyOn(clip, 'clipConcaveSolidSteps') 호출 0 test('준비는 프레임마다 생성기를 한 번만 진행한다', ...); test('별 두 팔 절단은 세 조각으로 교체되고 부피가 보존된다', ...); test('선이 지나간 조각만 자른다', ...); // targets=[a], b는 그대로 test('칼날 경로의 비대상 조각은 밀려나고 형태를 유지한다', ...); // pushedIds 포함, volume 동일, 발자국이 휩쓸림 다각형과 겹치지 않음 test('밀 자리가 없으면 noRoom', ...); test('모든 대상이 부스러기면 cutRejected', ...); test('cancelCut은 밀린 조각을 원위치로 되돌리고 준비 자원을 해제한다', ...); test('칼끝 쪽 대상의 progress가 먼저 1이 된다', ...); test('완료 후 revealAge가 증가하고 revealed가 된다', ...); test('dispose 후 지오메트리·재질이 모두 해제된다', ...); ``` - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/Healing2Scene.test.ts` - [ ] **Step 3: 구현** - [ ] **Step 4: 통과 확인과 파트 1 회귀** — Run: `npx vitest run tests/Healing2Scene.test.ts && npx vitest run tests/Healing` / Expected: PASS(파트 1 테스트 무변경 통과) - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 장면: 다중 절단·당겨 썰기·밀어내기"` ### Task 8: `HealingPart2Mode`, 편성·등록, 튜토리얼, 디버그 API **Files:** - Create: `src/core/modes/HealingPart2Mode.ts`, `src/stages/HealingPart2Stages.ts` - Modify: - `src/stages/LevelDefinition.ts:17, 38` (`LevelMode`에 `'healing2'`, `healing2?: HealingPart2Kind`) - `src/stages/StageDefinitions.ts:10`, `src/stages/stage-order.json`(끝에 세 ID) - `src/stages/StageVariant.ts:9,17`, `src/stages/LevelLabels.ts:15`, `src/stages/LevelBadge.ts:31` - `src/core/Game.ts:73,416` (import와 `ModeRegistry` 목록 등록. 분기는 추가하지 않는다) - `src/ui/HealingTutorial.ts:4-11, 44, 74-95` (`copy?: 'stroke'` 선택 필드, 동일성 비교에 포함) - `src/core/modes/GameMode.ts:34`, `src/core/JellyDebug.ts:35,92`, `src/core/GameDebug.ts:244,277` - `e2e/debug-api.spec.ts`, `docs/agent-reference/debug-api.md`, `src/i18n/texts.csv` - 레벨 수 고정 테스트: `tests/DestructionLevels.test.ts:13,17`, `tests/JellyFillLevels.test.ts:15`(210 → 213) - Test: `tests/HealingPart2Mode.test.ts`(`HealingMode.test.ts`의 가짜 ModeHost·`vi.mock(Healing2Scene)` 틀 재사용), `tests/HealingPart2Levels.test.ts`, `tests/HealingTutorial.test.ts`(기존 + stroke 문구) **Interfaces:** - Consumes: Task 5 `strokeSamples`·`crossedIds`·`handleAtB`·`STROKE_MIN_PX`, Task 7 `Healing2Scene`, `HealingGesture`(깊이 드래그), `host.discoverAndOfferExit()`, `host.progress.discover(jellyType)`. - Produces: - `HealingPart2Mode implements GameMode` — `traits`는 `HealingMode`와 같은 값, `menuReturn() { return { reload: true } }`. - `interface Healing2Debug { kind: HealingPart2Kind; phase: 'aim' | 'armed' | 'cut' | 'spread'; stroke: { a: ScreenPoint; b: ScreenPoint } | null; targets: number[]; pushed: number[]; depth: number; cuts: number; ready: boolean; spread: number; detached: boolean; revealed: boolean; knifePose: { x: number; y: number; z: number; pitch: number; yaw: number; roll: number; opacity: number }; chunks: { id: number; volume: number; center: ScreenPoint; screenBounds: { left: number; right: number; top: number; bottom: number }; tilt: number; progress: number; revealAge: number }[]; hint: string | null }` - `window.__jellyDebug.healing2(): Healing2Debug | null` 입력 규칙(spec "조작" 절): - **aim** - 첫 포인터의 `start`가 선 시작점이다. 이후 다른 포인터는 무시한다. - `move`마다 선분을 갱신하고, `strokeSamples`의 각 점을 카메라 레이로 `chunkObjects()`에 쏘아 `crossedIds`로 대상을 구한다. - 월드 선은 화면 a·b를 `screenToPlane(x, y, scene.topOf(targets))`로 투영해 만든다. 대상이 없으면 `topOf` 대신 `surfaceTop`을 쓴다. - `handleAtB`로 손잡이 방향을 정해 `setStroke`를 부른다. - 원래 포인터의 정상 `end`에서 선 길이가 `STROKE_MIN_PX` 이상이고 대상이 있으면 `confirmStroke()`를 부른다. - 결과 처리: - `'ok'`: phase `armed`, Slice mode 안내. - `'empty'`: `healing2.line_missed` 안내, aim 유지. - `'noRoom'`: 파트 1의 `knifeSpaceRejected` 안내(`HealingMode.ts:417-430`에서 쓰는 기존 키)를 재사용하고 aim 유지. - 튜토리얼에는 `copy: 'stroke'`와 함께 `markedPieces = targets.length`를 넘겨, 대상이 여럿이면 개수를 표시한다. - **armed·cut**: 파트 1과 같다. 새 접촉에서 `HealingGesture.startDepthDrag`, `moveDepth(y, min(160, 화면높이·0.18))` → `setDepth`, `host.feel.slicing(depth > lastDepth)`. `depth === 1`이면 `completeCut()` → `cuts++`, `boardTap`, `pulse([8])`. - **Reposition**: armed·cut이고 접촉 전일 때 `cancelCut()` → aim. - **복귀**: `scene.cutRejected`이면 aim으로 돌아가고 `healing2.cut_rejected` 안내. - **취소**: `cancelInput`·숨김은 aim 중이면 선을 지우고, 접촉 전 cut이면 `cancelCut()`. - **resize**: aim 중 `resize`는 선을 지운다. - **진행**: `!discovered && cuts > 0 && scene.revealed`일 때 한 번 `progress.discover` + `discoverAndOfferExit`. 문구(`texts.csv`, 영어 / 한국어): - `healing2.draw_line`: `Draw a line across the jelly` / `젤리를 가로질러 선을 그어 주세요` - `healing2.line_missed`: `Draw all the way across the jelly` / `젤리를 끝까지 가로질러 그어 주세요` - `healing2.cut_rejected`: `That cut leaves only crumbs. Try another line` / `부스러기만 남아요. 다른 선을 그어 주세요` - 튜토리얼(`copy: 'stroke'`) - 단계 제목: `Draw` / `선 긋기`, `Slice mode` / `Slice mode`(기존 키), `Open` / `열기` - 본문: - `Draw a line across the jelly. Let go to enter Slice mode.` / `젤리를 가로질러 선을 긋고 손을 떼면 Slice mode가 됩니다.` - `Drag down to pull the knife through. Drag up to lift it.` / `아래로 끌어 칼을 당겨 썰고, 위로 끌면 칼이 올라옵니다.` - `When the knife reaches the board, the slice opens.` / `칼이 도마에 닿으면 단면이 열립니다.` - [ ] **Step 1: 실패하는 테스트** — `tests/HealingPart2Mode.test.ts` ```ts test('선을 긋고 떼면 armed, 깊이는 0', ...); test('짧은 선·대상 없는 선은 확정하지 않고 line_missed 안내', ...); test('긋는 중 cancel·hidden은 확정하지 않는다', ...); // Review Focus 1 test('두 번째 포인터는 무시한다', ...); // Review Focus 2 test('cutRejected면 조준으로 복귀하고 안내한다', ...); // Review Focus 3 test('aim 중 resize는 선을 지운다', ...); // Review Focus 5 test('아래로 끌면 setDepth, 1에서 completeCut·boardTap 한 번', ...); test('Reposition은 접촉 전에만 cancelCut', ...); test('첫 단면에서 discover·discoverAndOfferExit 한 번', ...); ``` `tests/HealingPart2Levels.test.ts`: - 편성 끝의 세 ID와 표시 번호 211·212·213. - `mode === 'healing2'`, `healing2` kind 매핑. - 213이 마지막 판. - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/HealingPart2Mode.test.ts tests/HealingPart2Levels.test.ts` - [ ] **Step 3: 구현과 등록** - [ ] **Step 4: 통과 확인** — Run: `npm test && npm run build` / Expected: 전체 PASS. 레벨 수 테스트는 213, `HealingTutorial.test.ts`의 기존 문구 테스트도 무변경 통과. - [ ] **Step 5: 디버그 API E2E** — Run: `npm run build && npx playwright test e2e/debug-api.spec.ts --project=mobile-chromium` / Expected: PASS(`healing2` 키·타입 포함) - [ ] **Step 6: 커밋** — `git commit -m "힐링 파트 2 모드·편성·튜토리얼·디버그 API 연결"` ### Task 9: 젤리 외관과 단면 효과 셰이더 **Files:** - Create: `src/core/healing2/Healing2Appearance.ts`, `src/jelly/shaders/healing2.glsl.ts` - Modify: `src/core/modes/HealingPart2Mode.ts`(재질 팩토리와 품질 연결), `src/core/healing2/Healing2Scene.ts`(조각별 uniform 갱신) - Test: `tests/Healing2Appearance.test.ts`, `e2e/shader-compile.spec.ts`(세 kind 추가) **Interfaces:** - Consumes: Task 1 `healing2Inclusions`·`healing2Outline`·`HEALING2_MAX_TOP`, Task 7 `revealAge(id)`. - Produces: - `healing2Material(kind: HealingPart2Kind, low: boolean): THREE.MeshPhysicalMaterial` — `onBeforeCompile`로 `healing2Glsl(kind)`를 주입한다. 프로그램 키는 `'healing2-' + kind + (low ? '-low' : '')`. - uniform 계약(조각마다 재질 복제): - `uHealing2Time: number` — 장면 시간, 초. - `uHealing2Reveal: number` — `revealAge`, 열리지 않았으면 −1. - `uHealing2Low: number` — 0 또는 1. - `healing2Glsl(kind: HealingPart2Kind): string` - 원본 좌표 `hPos`, `hCut`을 쓰고 정점 셰이더 전달은 파트 1과 같다. - 내부 장식은 `healing2Inclusions`를 상수 배열로 굽은 해석적 SDF다(구, 발바닥 = 원 4개 + 큰 원, 보석 = 팔면체). - 시선 적분은 고정 거리 0.7을 고품질 16스텝, 저품질 8스텝으로 나눈다. 안/밖 판정은 GLSL로 옮긴 윤곽 SDF와 높이 함수로 한다. 젤리별 단면 효과(spec 표 그대로 구현): - 별 은하 - 단면은 깊은 남색의 투명한 밤하늘이다. - 별가루 반짝임은 원본 좌표 해시 셀로 만들고, 시간에 따라 밝기만 깜빡인다. - 은하수 띠는 단면 평면 위 원본 좌표에 고정하고 밝기만 흐른다. - 행성 구슬 단면에는 줄무늬가 보인다. - 발바닥 고양이: 맑은 장밋빛 속살, 단면 가장자리의 분홍·라벤더 빛무리, 잘린 발바닥 단면의 파스텔 발광. - 보석 왕관: 투명 속살의 무지갯빛 굴절(파장별 오프셋 3샘플, 저품질은 1샘플), 보석 단면의 면별 반사, 금빛 반짝이 가루, 금빛 외피 단면의 얇은 금 테두리. - 공통 시간 흐름 - `uHealing2Reveal` 0→1.0초 동안 반짝임 세기가 `1 + 2.2·(1 - t)²`배로 퍼진다. - 그 뒤 기본 세기로 유지된다. - 재절단으로 생긴 새 단면은 그 조각의 `revealAge`가 다시 0부터 시작한다. - 저품질은 반짝임 밀도와 굴절 샘플을 줄이되, 주제 색과 투명감은 유지한다. - [ ] **Step 1: 실패하는 테스트** — `tests/Healing2Appearance.test.ts` ```ts test('kind·품질마다 프로그램 키가 다르다', ...); test('세 uniform이 존재하고 재질 복제 시 독립적이다', ...); test('GLSL 문자열에 장식 개수만큼 상수가 들어간다', ...); test('저품질은 시선 스텝 8을 정의한다', ...); ``` `e2e/shader-compile.spec.ts`에 211~213판을 추가해 고·저품질 컴파일 오류가 없는지 검사한다. - [ ] **Step 2: 실패 확인** — Run: `npx vitest run tests/Healing2Appearance.test.ts` - [ ] **Step 3: 구현** - [ ] **Step 4: 통과 확인** — Run: `npx vitest run tests/Healing2Appearance.test.ts && npm run build && npx playwright test e2e/shader-compile.spec.ts --project=mobile-chromium` / Expected: PASS - [ ] **Step 5: 커밋** — `git commit -m "힐링 파트 2 세 젤리 외관과 단면 효과 셰이더"` ### Task 10: E2E, 전체 시각 검수, 문서 **Files:** - Create: `e2e/healing-part2.spec.ts`, `e2e/helpers/healing2.ts` - Modify: `docs/agent-reference/modes.md`(힐링 파트 2 절), `AGENTS.md`(작업별 상세 문서 표의 모드 범위에 "힐링 파트 2 211~213" 추가) - 캡처 결과: `.playwright-mcp/healing-part2/` **Interfaces:** - Consumes: Task 8 `healing2()` 디버그, `e2e/helpers.ts`의 `openLevel`·`installSampler`·`sampled`·`pressHudHome`. - Produces(`e2e/helpers/healing2.ts`): - `healing2State(page): Promise` - `drawStroke(page, a: ScreenPoint, b: ScreenPoint, touch = true): Promise` - `strokeAcross(page, ids: number[]): Promise<{ a: ScreenPoint; b: ScreenPoint }>` — `chunks[].screenBounds`로 해당 조각들을 밖에서 밖으로 가로지르는 선분을 계산한다. - `sliceDown(page, touch = true): Promise` — 화면 아래로 끌어 depth 1까지. - [ ] **Step 1: E2E 작성** — `e2e/healing-part2.spec.ts`. `LEVELS.filter(l => l.mode === 'healing2')` 루프로 다음을 검사한다. - 선 긋기 → `phase === 'armed'`, `depth === 0`, `knifePose.opacity === 1`. - `sliceDown` → `cuts === 1`, `revealed`, 별 3. 버튼은 `Next Jelly`이고 213판은 `Finish`. - 두 조각을 한 선으로 썰기 → `targets.length === 2`. `installSampler`로 칼끝 쪽 대상의 `progress`가 먼저 1이 되는지 확인한다. - 허공 선 → `phase === 'aim'`, `hint`가 `line_missed` 문구. - `Reposition` → aim, 밀린 조각 원위치. - 도감 재방문 → `Back`. - `"재진입 시 프로그램·텍스처가 누적되지 않는다"`(Review Focus 4): `healing-reference.spec.ts`의 측정 방식으로 211판을 3회 재진입해 `renderer.info` 프로그램·텍스처 수가 늘지 않는지 확인한다. - 파트 1 회귀: `e2e/healing.spec.ts`, `e2e/healing-reference.spec.ts`가 그대로 통과해야 한다. 210판은 이제 `Next Jelly`. - [ ] **Step 2: 실행** — Run: `npm run build && npx playwright test e2e/healing-part2.spec.ts e2e/healing.spec.ts e2e/healing-reference.spec.ts --project=mobile-chromium` / Expected: PASS. 부하로 실패하면 해당 파일을 단독 재실행하고, 최초 결과와 재실행 결과를 함께 기록한다. - [ ] **Step 3: 시각 캡처** — `HEALING2_VISUAL=1`일 때만 동작하는 캡처 테스트를 같은 파일에 둔다. - 범위: 211~213 × {대기, 선 긋기 중, 두 조각 대각선 썰기 중(depth 0.5), 단면이 열리는 반짝임(revealAge 0.3), 은은한 반짝임(revealAge 3)} × {390×844, 1280×800} = 30장. - 추가: `?quality=low` 모바일 단면 열림 3장. - 시간은 `__jellyDebug`의 시간 제어로 고정한다. 저장 경로는 `.playwright-mcp/healing-part2/--x.png`. - Run: `HEALING2_VISUAL=1 npx playwright test e2e/healing-part2.spec.ts -g "캡처" --project=mobile-chromium --project=desktop-chromium` - [ ] **Step 4: 레퍼런스 대조와 수정** - 각 캡처를 `references/HealingJelly/HealingJelly_{6,7,8}.png`와 spec 표에 대조한다. - 다음 항목에 위반이 없을 때까지 Task 1·9의 수치를 고치고 다시 촬영한다. - 실제보다 훨씬 예쁜가(투명·광택·과장된 영롱함). - 장식이 젤리 속에 떠 보이는가. - 단면 효과가 속살을 탁하게 가리지 않는가. - 오목 단면에 구멍·겹침이 없는가. - 칼·안내가 젤리 단면을 가리지 않는가. - 대조 결과와 수정 내역은 `.playwright-mcp/healing-part2/review.md`에 기록한다. 재촬영이 2회를 넘거나 범위가 늘면 사용자에게 먼저 알린다. - [ ] **Step 5: 문서** — `docs/agent-reference/modes.md`에 다음을 짧게 적는다. - 파트 2의 입력 규칙, 장면 구조, 절단기 제약(구멍 단면이면 `null`), 디버그 키. - `AGENTS.md` 표의 모드 열에 "힐링 파트 2 211~213"을 추가한다. - [ ] **Step 6: 전체 회귀** — Run: `npm test && npm run build && npm run e2e:mobile` / Expected: 모두 PASS - [ ] **Step 7: 커밋** — `git add e2e/healing-part2.spec.ts e2e/helpers/healing2.ts docs/agent-reference/modes.md AGENTS.md src/ && git commit -m "힐링 파트 2 E2E·시각 검수·문서"` ## 최종 독립 리뷰 - 모든 Task 완료 후 Opus 5.5 / High 리뷰어가 브랜치 전체 diff와 `.playwright-mcp/healing-part2/` 캡처·`review.md`를 검토한다. - 판정에서 제외하는 항목: 폰트 외관(폰트 작업의 사용자 담당), 시간적 반짝임 체감과 실기기 촉감·성능(사용자 확인 항목). - 병합할 때 `PROGRESS.md`에 결과와 미검증 항목을 기록한다.