import { t } from '../../i18n'; import { Raycaster, Vector2 } from 'three'; import type { Vec2 } from '../../geometry/Vec2'; import { polygonCentroid } from '../../geometry/Polygon'; import { JELLY_NAMES } from '../../ui/JellyThumbnail'; import type { HealingKind } from '../../stages/LevelDefinition'; import type { LevelDefinition } from '../../stages/LevelDefinition'; import type { StageResult } from '../../stages/StageSession'; import type { ScreenPoint } from '../../cutting/StrokeRecorder'; import { knifeResultView } from '../../ui/ResultViews'; import { TABLE_VIEW } from '../SceneManager'; import { HealingAimGesture } from '../healing/HealingAimGesture'; import { HealingScene } from '../healing/HealingScene'; import { HealingGesture, type HealingPhase } from '../healing/HealingGesture'; import type { GameMode, ModeHost, ModeTraits, ModeDebug, PointerHandlers } from './GameMode'; const AIM_RETURN_MARGIN = .9; export interface HealingDebug { kind: HealingKind; phase: HealingPhase; depth: number; cuts: number; angle: number; origin: Vec2; aim: ScreenPoint; aimWorld: Vec2; aimOnPiece: boolean; recenter: { returning: boolean; count: number; center: ScreenPoint }; guideVisible: boolean; touchGuides: { id: number; screen: ScreenPoint; length: number; chordLength: number; dent: number }[]; cutGuides: { id: number; screen: ScreenPoint }[]; aimMarker: { visible: boolean; screen: ScreenPoint }; bladeDirection: Vec2; ready: boolean; spread: number; detached: boolean; chunks: { id: number; selected: boolean; area: number; center: ScreenPoint; tilt: number }[]; knifePose: { x: number; y: number; z: number; pitch: number; yaw: number; roll: number }; knifeScreen: ScreenPoint; bounds: { left: number; right: number; top: number; bottom: number }; } /** 두 힐링 판의 위치·방향 조준과 수직 썰기를 전용 장면에 연결한다. */ export class HealingMode 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 aimGesture = new HealingAimGesture(); private nextAim = false; private returningAim: { start: Vec2; elapsed: number } | null = null; private returnContactBlocked = false; private recenterCount = 0; private aimOnPiece = true; private widerCutNeeded = false; private knifeRoomNeeded = false; private floatingKnifeDrag: { height: number; x: number; y: number } | null = null; private view: HealingScene | null = null; private control = new HealingGesture(); private origin: Vec2 = { x: 0, y: 0 }; private cuts = 0; private discovered = false; private contactPending = false; private movementTime = -Infinity; private lastDepth = 0; constructor(private readonly host: ModeHost) {} handles(level: LevelDefinition) { return level.mode === 'healing'; } hint() { return t('text.drag_across_the_board_to_move_use_two_fingers_to_rotate_release_on_a_piece_to_set_the_knife'); } tool() { return null; } scene(level: LevelDefinition) { return { view: { ...TABLE_VIEW, elevationDeg: 37, azimuthDeg: 12, azimuthPortrait: 10, fit: (level.healing === 'dragon-fruit' || level.healing === 'crystal-scales') ? 2.55 : 2.25, // 용과는 앞쪽 손잡이와 옆으로 누운 조각이 세로 화면 안에 함께 들어와야 한다. fitPortrait: level.healing === 'dragon-fruit' ? 3.2 : level.healing === 'aurora' ? 3.15 : level.healing === 'crystal-scales' ? 2.65 : 2.5, centerX: level.healing === 'aurora' ? -.30 : 0, targetY: 1.18, targetYPortrait: level.healing === 'galaxy' || level.healing === 'opal' ? .65 : level.healing === 'crystal-scales' ? .8 : .9, }, sceneK: 0, }; } enter(level: LevelDefinition): void { this.floatingKnifeDrag = null; this.returningAim = null; this.returnContactBlocked = false; this.recenterCount = 0; this.view = new HealingScene(this.host.content, level.healing!, this.host.render.sceneMgr.camera); this.control = new HealingGesture(); this.aimGesture.reset(); this.nextAim = false; this.aimOnPiece = true; this.widerCutNeeded = false; this.knifeRoomNeeded = false; this.cuts = 0; this.discovered = false; this.contactPending = false; this.origin = this.view.initialAim(this.control.angle); this.view.setAim(this.origin, this.control.angle, this.aimOnPiece); this.view.setBladeVisible(true); this.view.setGuide(true, false); this.host.ui.hidePanel(); } exit(): void { this.host.feel.slicing(false); this.view?.dispose(); this.view = null; 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.hint()); this.host.ui.setHintVisible(true); } menuReturn() { return { reload: true }; } onMenu() { this.cancelInput(); } cancelInput(): void { this.floatingKnifeDrag = null; this.aimGesture.reset(); this.control.endDepthDrag(); this.returnContactBlocked = false; this.host.feel.slicing(false); if (this.view && this.control.phase === 'aim') this.view.setGuide(true, false); 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() { return { levelId: this.host.level().id, targetLabel: t('text.jelly'), target: this.host.level().healing === 'dragon-fruit' ? t('text.crystal_pitaya') : JELLY_NAMES[this.host.level().jellyType], countLabel: t('text.slices'), used: this.cuts, max: 0, pips: false, unlimited: true, extra: null, healingTutorial: { markedPieces: this.view?.markedPieces, hasTipTouch: this.view?.hasTipTouch, widerCutNeeded: this.widerCutNeeded, knifeRoomNeeded: this.knifeRoomNeeded, onReposition: this.reposition, stage: this.control.phase === 'spread' ? 'opening' as const : this.control.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 {} private readonly reposition = (): void => { const view = this.view; if (!view || this.contactPending || (this.control.phase !== 'armed' && this.control.phase !== 'cut')) return; this.aimGesture.reset(); this.control.resetAim(); this.lastDepth = 0; this.host.feel.slicing(false); view.cancelCut(); view.setAim(this.origin, this.control.angle, this.aimOnPiece); view.setGuide(true); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); this.host.ui.setHint(this.hint()); this.host.ui.updateHud(); }; /** 마지막 접점을 정상적으로 뗀 조준만 확정한다. */ private confirmAim(): void { if (!this.canDraw() || !this.host.canDraw() || this.control.phase !== 'aim' || !this.view) return; if (!this.aimOnPiece) return; if (!this.view.canCut(this.origin, this.control.angle)) { this.widerCutNeeded = true; this.host.ui.updateHud(); return; } this.aimGesture.reset(); this.widerCutNeeded = false; this.knifeRoomNeeded = false; this.control.phase = 'armed'; this.view.lockKnifeAim(); this.floatingKnifeDrag = null; this.view.setGuide(true, false); this.host.ui.hideTutorial(); this.host.ui.setHint(t('text.drag_down_to_slice_drag_up_to_lift_the_knife')); this.host.ui.updateHud(); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); } /** 화면상의 칼 중심으로 실제 표면을 고른다. 빈 공간도 같은 화면 이동을 이어간다. */ private moveAimTo(p: ScreenPoint, directTouch = false): boolean { const view = this.view!; this.beginKnifeDrag(); const rect = this.host.render.canvasRect(); this.pickNdc.set(2 * (p.x - rect.left) / rect.width - 1, 1 - 2 * (p.y - rect.top) / rect.height); const camera = this.host.render.camera; camera.updateMatrixWorld(); this.pickRay.setFromCamera(this.pickNdc, camera); const hit = view.aimAt(this.pickRay.ray); // 아무 곳이나 잡아서 상대 이동할 수 있다. 직접 조각을 짚었을 때만 즉시 옮긴다. if (directTouch && !hit) return false; const q = hit ?? this.host.render.sceneMgr.screenToPlane(p.x, p.y, 0, rect); if (!q) return false; const bounds = view.boardHalfSize; if (!directTouch && (Math.abs(q.x) > bounds.x + AIM_RETURN_MARGIN || Math.abs(q.z) > bounds.y + AIM_RETURN_MARGIN)) { this.returningAim = { start: { x: this.origin.x + view.bodyOffset.x, y: this.origin.y + view.bodyOffset.y }, elapsed: 0 }; this.returnContactBlocked = true; this.recenterCount++; this.floatingKnifeDrag = null; view.beginKnifeAim(); return false; } this.origin = { x: q.x - view.bodyOffset.x, y: q.z - view.bodyOffset.y }; this.aimOnPiece = !!hit; this.widerCutNeeded = false; this.knifeRoomNeeded = false; this.moveFloatingKnife(p); return true; } private beginKnifeDrag(): void { if (this.floatingKnifeDrag || !this.view) return; const view = this.view; view.beginKnifeAim(); const aim = this.host.plateToScreen({ x: this.origin.x + view.bodyOffset.x, y: this.origin.y + view.bodyOffset.y }, view.aimHeight); const root = this.host.plateToScreen({ x: view.knife.position.x, y: view.knife.position.z }, view.knife.position.y); this.floatingKnifeDrag = { height: view.knife.position.y, x: root.x - aim.x, y: root.y - aim.y }; } private moveFloatingKnife(point: { x: number; y: number }): void { const floating = this.floatingKnifeDrag; if (!floating || !this.view) return; const rect = this.host.render.canvasRect(); this.pickNdc.set(2 * (point.x + floating.x - rect.left) / rect.width - 1, 1 - 2 * (point.y + floating.y - rect.top) / rect.height); this.pickRay.setFromCamera(this.pickNdc, this.host.render.camera); this.view.setKnifeAim(this.pickRay.ray, floating.height); } /** 얇은 조각도 긴 축을 가로질러 썰 수 있는 기본 방향부터 제시한다. */ private centerAim(): void { const view = this.view!; this.origin = polygonCentroid(view.bodyPolygon); if (view.canCut(this.origin, this.control.angle)) return; const angle = this.control.angle; for (let i = 1; i <= 12; i++) { const offset = Math.ceil(i / 2) * Math.PI / 12 * (i % 2 ? 1 : -1); if (view.canCut(this.origin, angle + offset)) { this.control.angle = angle + offset; return; } } } pointer(): PointerHandlers { return this.pointerHandlers; } private readonly pointerHandlers: PointerHandlers = { start: p => { if (!this.canDraw()) return; if (this.returningAim || this.returnContactBlocked) { this.aimGesture.start(p); this.returnContactBlocked = true; return; } if (this.control.phase === 'aim' && this.aimGesture.count === 0) this.beginKnifeDrag(); if (this.nextAim && this.control.phase === 'aim' && this.aimGesture.count === 0 && !p.shift && this.moveAimTo(p, true)) { this.view?.setAim(this.origin, this.control.angle, this.aimOnPiece); this.host.ui.updateHud(); } this.aimGesture.start(p); if (this.aimGesture.count === 2) this.control.endDepthDrag(); if (this.aimGesture.count === 1) this.control.startDepthDrag(p.y); this.view?.setGuide(this.control.phase === 'aim' || this.control.depth === 0, this.aimGesture.rotating); }, move: p => { const v = this.view; if (!v || !this.canDraw() || !this.aimGesture.has(p.id) || this.returningAim || this.returnContactBlocked) return; const world = { x: this.origin.x + v.bodyOffset.x, y: this.origin.y + v.bodyOffset.y }; const center = this.host.plateToScreen(world, v.aimHeight); const delta = this.aimGesture.move(p, center); if (this.control.phase === 'aim') { if (delta.rotation) { const d = this.direction(); const a = Math.atan2(d.y, d.x) + delta.rotation; const q = this.host.render.sceneMgr.screenToPlane(center.x + Math.cos(a) * 100, center.y + Math.sin(a) * 100, v.aimHeight, this.host.render.canvasRect()); if (q) { const angle = Math.atan2(q.z - world.y, q.x - world.x); this.control.angle = angle; } if (this.floatingKnifeDrag) { const { x, y } = this.floatingKnifeDrag; this.floatingKnifeDrag.x = x * Math.cos(delta.rotation) - y * Math.sin(delta.rotation); this.floatingKnifeDrag.y = x * Math.sin(delta.rotation) + y * Math.cos(delta.rotation); this.moveFloatingKnife(center); } } else if (delta.dx || delta.dy) { this.moveAimTo({ x: center.x + delta.dx, y: center.y + delta.dy }); } if (this.returningAim) return; v.setAim(this.origin, this.control.angle, this.aimOnPiece); v.setGuide(true, this.aimGesture.rotating); this.host.ui.updateHud(); } else if (this.aimGesture.count === 1) { 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 (!this.aimGesture.has(p.id)) return; // 두 손에서 한 손으로 바뀔 때는 조준을 이어가고 마지막 손 뗌만 확정한다. this.pointerHandlers.move(p); this.aimGesture.end(p.id); if (this.returnContactBlocked || this.returningAim) { if (!this.aimGesture.count) this.returnContactBlocked = false; return; } if (!this.aimGesture.count && this.control.phase === 'aim') { this.confirmAim(); this.floatingKnifeDrag = null; } const remaining = this.aimGesture.first; this.control.endDepthDrag(); if (remaining) this.control.startDepthDrag(remaining.y); this.host.feel.slicing(false); this.view?.setGuide(this.control.phase === 'aim' || this.control.depth === 0, this.aimGesture.rotating); if (!this.aimGesture.count && this.host.state.phase === 'drawing') this.host.state.to('ready'); }, cancel: () => this.cancelInput(), }; private direction(): Vec2 { const v = this.view!; const angle = this.control.angle; const world = { x: this.origin.x + v.bodyOffset.x, y: this.origin.y + v.bodyOffset.y }; const height = v.aimHeight; const a = this.host.plateToScreen(world, height); const b = this.host.plateToScreen({ x: world.x + Math.cos(angle), y: world.y + Math.sin(angle) }, height); const len = Math.hypot(b.x - a.x, b.y - a.y) || 1; return { x: (b.x - a.x) / len, y: (b.y - a.y) / len }; } private finishContact(): void { const v = this.view; if (!v || !this.contactPending) return; if (!v.completeCut(this.origin, this.control.angle)) return; this.contactPending = false; this.host.feel.slicing(false); this.cuts++; this.host.feel.sound('boardTap'); this.host.feel.pulse([8]); this.host.ui.updateHud(); v.setGuide(false); this.aimGesture.reset(); if (this.host.state.phase === 'drawing') this.host.state.to('ready'); } private rest(): void { this.lastDepth = 0; this.floatingKnifeDrag = null; const v = this.view; if (!v) return; this.centerAim(); this.aimOnPiece = true; v.setDepth(0); v.setAim(this.origin, this.control.angle, this.aimOnPiece); v.setBladeVisible(true); } beforePieces(dt: number, _base: number, _realDt: number): void { if (this.returningAim && this.view) { const returning = this.returningAim; returning.elapsed = Math.min(.45, returning.elapsed + Math.max(0, dt)); const t = returning.elapsed / .45; const eased = t * t * (3 - 2 * t); const world = t === 1 ? { x: 0, y: 0 } : { x: returning.start.x * (1 - eased), y: returning.start.y * (1 - eased) }; const hit = this.view.aimAtBoardPoint(world); const offset = this.view.bodyOffset; this.origin = { x: world.x - offset.x, y: world.y - offset.y }; this.aimOnPiece = !!hit; this.widerCutNeeded = false; this.knifeRoomNeeded = false; this.view.setAim(this.origin, this.control.angle, this.aimOnPiece, t === 1 ? 1 : 1 - Math.exp(-Math.max(0, dt) * 15)); this.view.setGuide(true, false); if (t === 1) this.returningAim = null; this.host.ui.updateHud(); } this.view?.update(dt); // 도마 투영에서 충분해 보였던 얇은 모서리도 실제 체적이 너무 작으면 조준으로 돌린다. if (this.view?.cutRejected && (this.control.phase === 'cut' || this.contactPending)) { const knifeSpaceRejected = this.view.knifeSpaceRejected; this.contactPending = false; this.control.resetAim(); this.widerCutNeeded = !knifeSpaceRejected; this.knifeRoomNeeded = knifeSpaceRejected; this.cancelInput(); this.view.cancelCut(); this.rest(); this.view.setGuide(true); if (knifeSpaceRejected) this.host.ui.setHint(t('text.turn_the_knife_to_make_room_for_its_handle_instruction')); this.host.ui.updateHud(); return; } this.finishContact(); const revealed = !!this.view?.revealed; const revealFinished = !!this.view?.automaticRevealFinished; if (!this.contactPending && this.control.phase === 'spread' && revealFinished && this.view) { this.control.resetAim(); this.nextAim = true; this.rest(); this.view.setGuide(true, false); this.host.ui.setHint(this.hint()); this.host.ui.updateHud(); } // 목표 위치가 아니라 실제 조각이 벌어져 단면을 드러낸 뒤 발견을 저장한다. if (!this.discovered && this.cuts > 0 && revealed) { this.discovered = true; this.host.progress.discover(this.host.level().jellyType); this.host.discoverAndOfferExit(); } if (this.host.clock.time - this.movementTime > 0.075) this.host.feel.slicing(false); } debug(): ModeDebug { return { healing: () => this.snapshot() }; } private snapshot(): HealingDebug | null { const v = this.view; if (!v) return null; const world = { x: this.origin.x + v.bodyOffset.x, y: this.origin.y + v.bodyOffset.y }; const corners = v.bodyPolygon.flatMap((p) => [ this.host.plateToScreen({ x: p.x + v.bodyOffset.x, y: p.y + v.bodyOffset.y }, 0), this.host.plateToScreen({ x: p.x + v.bodyOffset.x, y: p.y + v.bodyOffset.y }, v.surfaceHeight), ]); return { kind: this.host.level().healing!, phase: this.control.phase, depth: this.control.depth, cuts: this.cuts, angle: this.control.angle, origin: { ...this.origin }, aim: this.host.plateToScreen(world, v.aimHeight), aimWorld: world, aimOnPiece: this.aimOnPiece, recenter: { returning: !!this.returningAim, count: this.recenterCount, center: this.host.plateToScreen({ x: 0, y: 0 }, 0) }, guideVisible: v.cutGuideVisible, touchGuides: v.touchGuideMarkers.map(p => ({ id: p.id, screen: this.host.plateToScreen({ x: p.x, y: p.z }, p.y), length: p.length, chordLength: p.chordLength, dent: p.dent })), cutGuides: v.cutGuideMarkers.map(p => ({ id: p.id, screen: this.host.plateToScreen({ x: p.x, y: p.z }, p.y) })), aimMarker: { visible: v.aimMarker.visible, screen: this.host.plateToScreen({ x: v.aimMarker.position.x, y: v.aimMarker.position.z }, v.aimMarker.position.y), }, bladeDirection: this.direction(), ready: this.canDraw(), spread: v.spreadAmount, detached: v.detached, chunks: v.describeChunks().map(c => ({ id: c.id, selected: c.selected, area: c.area, tilt: c.tilt, center: this.host.plateToScreen({ x: c.center.x, y: c.center.z }, c.center.y), })), knifeScreen: this.host.plateToScreen({ x: v.knife.position.x, y: v.knife.position.z }, v.knife.position.y), knifePose: { x: v.knife.position.x, y: v.knife.position.y, z: v.knife.position.z, pitch: v.knife.rotation.x, yaw: v.knife.rotation.y, roll: v.knife.rotation.z, }, bounds: { left: Math.min(...corners.map((p) => p.x)), right: Math.max(...corners.map((p) => p.x)), top: Math.min(...corners.map((p) => p.y)), bottom: Math.max(...corners.map((p) => p.y)), }, }; } }