import { t } from '../../i18n'; import { Box3, Raycaster, Vector2, Vector3, type Object3D } from 'three'; import { JELLY_NAMES } from '../../ui/JellyThumbnail'; import type { HealingPart2Kind, LevelDefinition } from '../../stages/LevelDefinition'; import type { StageResult } from '../../stages/StageSession'; import type { ScreenPoint } from '../../cutting/StrokeRecorder'; import { knifeResultView } from '../../ui/ResultViews'; import type { QualityTierLevel } from '../QualityTier'; import { TABLE_VIEW } from '../SceneManager'; import { HealingGesture, type HealingPhase } from '../healing/HealingGesture'; import { healing2Material } from '../healing2/Healing2Appearance'; import { Healing2Scene } from '../healing2/Healing2Scene'; import { STROKE_MIN_PX, crossedIds, handleAtB, strokeSamples } from '../healing2/StrokeAim'; import type { GameMode, ModeHost, ModeTraits, ModeDebug, PointerHandlers } from './GameMode'; /** 장면의 자동 벌림 시간(Healing2Scene 의 REVEAL_END)과 같다. 벌림이 끝난 뒤 다시 선 긋기를 받는다. */ const REVEAL_SEC = 1.55; /** 썰기 입력이 멈춘 뒤 long 절단음을 끄는 시간(초, 파트 1과 같다) */ const SLICING_IDLE_SEC = 0.075; type ScreenRect = { left: number; right: number; top: number; bottom: number }; export interface Healing2Debug { kind: HealingPart2Kind; phase: HealingPhase; /** 긋는 중이거나 확정한 선의 화면 좌표(확정 시점 기준). 없으면 null */ 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: ScreenRect; tilt: number; progress: number; revealAge: number }[]; /** 지금 떠 있는 안내(대상 없는 선·부스러기·손잡이 공간). 없으면 null */ hint: string | null; } /** * 힐링 파트 2(212~214판): 한 손가락으로 젤리를 가로지르는 선을 그어 위치와 방향을 한 번에 정하고, * 손을 떼면 Slice mode 로 들어가 새 접촉의 세로 드래그로 칼을 당겨 썬다. */ export class HealingPart2Mode implements GameMode { readonly traits: ModeTraits = { input: 'pointer', tablePieces: false, inputPlaneY: 1.4, shakeWhileReady: false, landShake: false, suppressMotion: true, judgeBelow: false, landingHud: false, }; private readonly pickRay = new Raycaster(); private readonly pickNdc = new Vector2(); private readonly box = new Box3(); private readonly corner = new Vector3(); private view: Healing2Scene | null = null; private control = new HealingGesture(); /** 선 긋기·깊이 조절을 맡은 첫 포인터. 다른 포인터는 무시한다 */ private pointerId: number | null = null; private line: { a: ScreenPoint; b: ScreenPoint } | null = null; private targets: number[] = []; private notice: string | null = null; private knifeRoomNeeded = false; private nextAim = false; private cuts = 0; private discovered = false; private contactPending = false; private spreadTime = 0; private movementTime = -Infinity; private lastDepth = 0; /** 마지막으로 본 창 CSS 크기. 품질 등급 변경도 onResize 를 부르므로 실제 크기 변화만 가려낸다 */ private lastSize = { width: 0, height: 0 }; constructor(private readonly host: ModeHost) {} handles(level: LevelDefinition) { return level.mode === 'healing2'; } hint() { return t('healing2.draw_line'); } tool() { return null; } scene(level: LevelDefinition) { const star = level.healing2 === 'star-galaxy'; return { view: { ...TABLE_VIEW, elevationDeg: 37, azimuthDeg: 12, azimuthPortrait: 10, // 젤리가 화면에서 충분히 크게 보이도록 파트 1보다 가깝게 잡는다(뒤쪽 대기 칼과 벌어진 조각은 화면 안에 남는다). fit: star ? 1.75 : 1.9, fitPortrait: star ? 1.8 : 1.95, centerX: 0, targetY: .6, targetYPortrait: star ? .05 : .15, }, sceneK: 0, }; } enter(level: LevelDefinition): void { // 진입 시 등급으로 재질 변형(시선 스텝 상한)을 고른다. 판 중간 등급 변경은 uHealing2Low 로 따라가 재컴파일하지 않는다. const low = this.host.tier() === 'low'; this.view = new Healing2Scene(this.host.content, level.healing2!, this.host.render.sceneMgr.camera, kind => healing2Material(kind, low)); this.view.setLowQuality(low); this.control = new HealingGesture(); this.pointerId = null; this.line = null; this.targets = []; this.notice = null; this.knifeRoomNeeded = false; this.nextAim = false; this.cuts = 0; this.discovered = false; this.contactPending = false; this.spreadTime = 0; this.lastDepth = 0; this.lastSize = this.host.viewSize(); this.host.ui.hidePanel(); } exit(): void { this.host.feel.slicing(false); this.view?.dispose(); this.view = null; this.pointerId = null; this.line = null; this.targets = []; this.contactPending = false; } canDraw() { return !!this.view && this.control.phase !== 'spread' && !this.contactPending && !this.host.lifecycle.hidden && !this.host.contextLost(); } onPlayUi() { this.host.ui.setHint(this.notice ?? this.hint()); this.host.ui.setHintVisible(true); } menuReturn() { return { reload: true }; } onMenu() { this.cancelInput(); } onTierChange(tier: QualityTierLevel) { this.view?.setLowQuality(tier === 'low'); } /** * 화면 좌표 선은 크기가 바뀌면 어긋나므로 조준 중이면 지운다. Slice mode 는 월드 좌표로 고정되어 유지한다. * 품질 등급 변경(setTier)은 CSS 크기가 그대로여도 이 훅을 부르므로, 크기가 실제로 바뀐 경우에만 지운다. */ onResize() { const size = this.host.viewSize(); const changed = size.width !== this.lastSize.width || size.height !== this.lastSize.height; this.lastSize = size; if (!changed || this.control.phase !== 'aim' || (!this.line && this.pointerId === null)) return; this.pointerId = null; this.clearStroke(); } /** 조준 중이면 선을 지우고, 도마 접촉 전 썰기는 되돌린다. 접촉 뒤 분리 상태는 보존한다. */ cancelInput(): void { this.pointerId = null; this.control.endDepthDrag(); this.host.feel.slicing(false); if (this.view && this.control.phase === 'aim' && this.line) this.clearStroke(); else if (this.view && this.control.phase === 'cut' && !this.contactPending) this.backToAim(); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); } materialOptions() { return undefined; } resultView(r: StageResult, ctx: { minAccuracy: number }) { return knifeResultView(r, ctx.minAccuracy); } hudInfo() { const phase = this.control.phase; return { levelId: this.host.level().id, targetLabel: t('text.jelly'), target: JELLY_NAMES[this.host.level().jellyType], countLabel: t('text.slices'), used: this.cuts, max: 0, pips: false, unlimited: true, extra: null, healingTutorial: { copy: 'stroke' as const, markedPieces: phase === 'aim' ? this.targets.length : this.view?.targetIds.length, knifeRoomNeeded: this.knifeRoomNeeded, // 대상 없는 선·부스러기 안내는 튜토리얼 제목으로 보인다(젤리 위 판정 문구는 긴 문장이 화면 밖으로 넘친다). notice: phase === 'aim' && !this.knifeRoomNeeded ? this.notice ?? undefined : undefined, onReposition: this.reposition, stage: phase === 'spread' ? 'opening' as const : phase === 'aim' ? (this.nextAim ? 'repeat' as const : 'aim' as const) : 'cut' as const, }, }; } sweepPieces() { return []; } afterGuides(_dt: number): void {} afterPieces(_dt: number): void {} beforeRender(): void {} /** 도마 접촉 전까지 Slice mode 에서 선 긋기 조준으로 돌아간다(밀린 조각도 장면이 복원한다). */ private readonly reposition = (): void => { if (!this.view || this.contactPending || (this.control.phase !== 'armed' && this.control.phase !== 'cut')) return; this.pointerId = null; this.backToAim(); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); }; pointer(): PointerHandlers { return this.pointerHandlers; } private readonly pointerHandlers: PointerHandlers = { start: p => { if (!this.canDraw() || this.pointerId !== null) return; this.pointerId = p.id; if (this.control.phase === 'aim') { this.line = { a: { x: p.x, y: p.y }, b: { x: p.x, y: p.y } }; this.targets = []; this.knifeRoomNeeded = false; this.clearNotice(); this.host.ui.updateHud(); } else { this.control.startDepthDrag(p.y); } }, move: p => { const v = this.view; if (!v || p.id !== this.pointerId || !this.canDraw()) return; if (this.control.phase === 'aim') { if (!this.line) return; this.line.b = { x: p.x, y: p.y }; const count = this.targets.length; this.updateStroke(); if (this.targets.length !== count) this.host.ui.updateHud(); return; } const depth = this.control.moveDepth(p.y, Math.min(160, this.host.viewSize().height * .18)); if (depth === null) return; v.setDepth(depth); this.host.feel.slicing(depth > this.lastDepth); this.movementTime = this.host.clock.inputTime(); this.lastDepth = depth; if (depth === 1) { this.contactPending = true; this.finishContact(); } }, end: p => { if (p.id !== this.pointerId) return; this.pointerHandlers.move(p); this.pointerId = null; if (this.control.phase === 'aim') this.confirmStroke(); else { this.control.endDepthDrag(); this.host.feel.slicing(false); } if (this.host.state.phase === 'drawing') this.host.state.to('ready'); }, cancel: () => this.cancelInput(), }; /** 화면 선분이 실제로 가로지른 조각을 고르고, 대상 윗면 높이의 수평면에 투영한 월드 선을 장면에 넘긴다. */ private updateStroke(): void { const v = this.view, s = this.line; if (!v || !s) return; const started = performance.now(); const rect = this.host.render.canvasRect(); const camera = this.host.render.camera; camera.updateMatrixWorld(); const objects = v.chunkObjects(); for (const o of objects) o.updateWorldMatrix(true, false); this.targets = crossedIds(strokeSamples(s.a, s.b).map(q => this.pick(q, objects, rect))); const height = this.targets.length ? v.topOf(this.targets) : v.surfaceTop; const a = this.host.render.sceneMgr.screenToPlane(s.a.x, s.a.y, height, rect); const b = this.host.render.sceneMgr.screenToPlane(s.b.x, s.b.y, height, rect); const length = a && b ? Math.hypot(b.x - a.x, b.z - a.z) : 0; if (!a || !b || length < 1e-6) v.setStroke(null); else v.setStroke({ a: { x: a.x, y: a.z }, b: { x: b.x, y: b.z }, height, origin: { x: (a.x + b.x) / 2, y: (a.z + b.z) / 2 }, dir: { x: (b.x - a.x) / length, y: (b.z - a.z) / length }, targets: [...this.targets], handleAtEnd: handleAtB(s.a, s.b), }); this.host.measure.stroke(performance.now() - started); } private pick(q: ScreenPoint, objects: Object3D[], rect: DOMRect): number | null { this.pickNdc.set(2 * (q.x - rect.left) / rect.width - 1, 1 - 2 * (q.y - rect.top) / rect.height); this.pickRay.setFromCamera(this.pickNdc, this.host.render.camera); const hit = this.pickRay.intersectObjects(objects, false)[0]; return hit ? this.view!.chunkIdOf(hit.object) : null; } /** 원래 포인터의 정상 손 뗌에서만 부른다. 충분히 길고 대상이 있는 선만 Slice mode 로 확정한다. */ private confirmStroke(): void { const v = this.view, s = this.line; if (!v || !s) return; if (!this.canDraw() || !this.host.canDraw()) { this.clearStroke(); return; } if (Math.hypot(s.b.x - s.a.x, s.b.y - s.a.y) < STROKE_MIN_PX || !this.targets.length) { this.clearStroke(); this.showNotice(t('healing2.line_missed')); return; } const result = v.confirmStroke(); if (result === 'ok') { this.control.phase = 'armed'; this.lastDepth = 0; this.knifeRoomNeeded = false; this.clearNotice(); this.host.ui.hideTutorial(); this.host.ui.setHint(t('healing2.tutorial_pull')); this.host.ui.updateHud(); return; } this.clearStroke(); if (result === 'noRoom') this.showKnifeRoomNotice(); else this.showNotice(t('healing2.line_missed')); } private clearStroke(): void { this.line = null; this.targets = []; this.view?.setStroke(null); this.host.ui.updateHud(); } /** 파트 1의 손잡이 자리 안내를 그대로 쓴다(튜토리얼 제목과 하단 안내). */ private showKnifeRoomNotice(): void { this.knifeRoomNeeded = true; this.notice = t('text.turn_the_knife_to_make_room_for_its_handle_instruction'); this.host.ui.setHint(this.notice); this.host.ui.updateHud(); } private showNotice(text: string): void { this.notice = text; this.host.ui.setHint(text); this.host.ui.updateHud(); } private clearNotice(): void { if (this.notice === null) return; this.notice = null; this.host.ui.setHint(this.hint()); this.host.ui.updateHud(); } /** 미완 절단을 되돌리고 선 긋기 조준으로 돌아간다. */ private backToAim(): void { this.control.resetAim(); this.contactPending = false; this.lastDepth = 0; this.host.feel.slicing(false); this.view?.cancelCut(); this.line = null; this.targets = []; this.host.ui.setHint(this.notice ?? this.hint()); this.host.ui.updateHud(); } /** 깊이 1 에 닿은 뒤 장면의 준비가 끝나 실제로 나뉠 때 한 번의 칼질로 기록한다. */ private finishContact(): void { const v = this.view; if (!v || !this.contactPending) return; if (!v.completeCut()) return; this.contactPending = false; this.pointerId = null; this.spreadTime = 0; this.line = null; this.targets = []; this.host.feel.slicing(false); this.cuts++; this.host.feel.sound('boardTap'); this.host.feel.pulse([8]); this.host.ui.updateHud(); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); } beforePieces(dt: number, _base: number, _realDt: number): void { const v = this.view; if (!v) return; v.update(dt); // 준비 중 대상이 모두 부스러기로 빠지거나 빠진 대상을 밀어낼 자리가 없으면 Slice mode 에 갇히지 않고 조준으로 돌아간다. if (v.cutRejected && (this.control.phase === 'armed' || this.control.phase === 'cut' || this.contactPending)) { const noRoom = v.cutRejectReason === 'noRoom'; this.pointerId = null; this.control.endDepthDrag(); this.backToAim(); // 공간 부족이면 확정 때의 noRoom 과 같은 손잡이·공간 안내를 쓴다. if (noRoom) this.showKnifeRoomNotice(); else this.showNotice(t('healing2.cut_rejected')); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); return; } this.finishContact(); if (!this.contactPending && this.control.phase === 'spread') { this.spreadTime += Math.max(0, dt); if (this.spreadTime >= REVEAL_SEC) { this.control.resetAim(); this.nextAim = true; this.lastDepth = 0; this.host.ui.setHint(this.hint()); this.host.ui.updateHud(); } } // 목표 위치가 아니라 실제 조각이 벌어져 단면을 드러낸 뒤 발견을 저장한다. if (!this.discovered && this.cuts > 0 && v.revealed) { this.discovered = true; this.host.progress.discover(this.host.level().jellyType); this.host.discoverAndOfferExit(); } if (this.host.clock.time - this.movementTime > SLICING_IDLE_SEC) this.host.feel.slicing(false); } debug(): ModeDebug { return { healing2: () => this.snapshot() }; } private snapshot(): Healing2Debug | null { const v = this.view; if (!v) return null; const objects = new Map(); for (const o of v.chunkObjects()) { const id = v.chunkIdOf(o); if (id !== null) objects.set(id, o); } return { kind: v.kind, phase: this.control.phase, stroke: this.line ? { a: { ...this.line.a }, b: { ...this.line.b } } : null, targets: this.control.phase === 'aim' ? [...this.targets] : v.targetIds, pushed: v.pushedIds, depth: this.control.depth, cuts: this.cuts, ready: this.canDraw(), spread: v.spreadAmount, detached: v.detached, revealed: v.revealed, knifePose: v.knifePose(), chunks: v.describeChunks().map(c => ({ id: c.id, volume: c.volume, tilt: c.tilt, progress: c.progress, revealAge: v.revealAge(c.id), center: this.host.plateToScreen({ x: c.center.x, y: c.center.z }, c.center.y), screenBounds: this.screenBounds(objects.get(c.id)), })), hint: this.notice, }; } /** 조각 객체의 월드 경계 상자 꼭짓점 8개를 화면에 투영한 경계 */ private screenBounds(object: Object3D | undefined): ScreenRect { const out = { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity }; if (!object) return out; this.box.setFromObject(object); if (this.box.isEmpty()) return out; for (let i = 0; i < 8; i++) { this.corner.set(i & 1 ? this.box.max.x : this.box.min.x, i & 2 ? this.box.max.y : this.box.min.y, i & 4 ? this.box.max.z : this.box.min.z); const s = this.host.jellyToScreen({ x: this.corner.x, y: this.corner.z }, this.corner.y); out.left = Math.min(out.left, s.x); out.right = Math.max(out.right, s.x); out.top = Math.min(out.top, s.y); out.bottom = Math.max(out.bottom, s.y); } return out; } }