import * as THREE from 'three'; import type { LevelDefinition } from '../../stages/LevelDefinition'; import type { QualityTierLevel } from '../QualityTier'; import { CRUSH } from '../../stages/Crush'; import { BEAR_CRUSH, BEAR_COLORS } from '../../stages/BearCrush'; import { BearCrushSession, type BearImpact } from '../../stages/BearCrushSession'; import { CrushScene } from '../crush/CrushScene'; import { PressJellyPrep } from '../crush/PressJellyPrep'; import { getJellyShape } from '../../jelly/JellyShapes'; import { JellyPiece } from '../../jelly/JellyPiece'; import { createJellyMaterial } from '../../jelly/JellyMaterial'; import type { PieceData } from '../../cutting/CutResolver'; import { disposePrepared } from '../PieceOps'; import { TABLE_VIEW } from '../SceneManager'; import { bearCrushResultView } from '../../ui/ResultViews'; import type { StageResult } from '../../stages/StageSession'; import type { CameraContext, GameMode, ModeDebug, ModeHost, ModePrep, ModeTraits, StrokeHandlers } from './GameMode'; /** 판 준비 결과: 장면에 붙이기 전 곰 젤리와 그 재질 등급 */ interface BearPrep {pieces:JellyPiece[];tier:QualityTierLevel} /** 입력 판정은 순수 세션, 시각 자원은 장면과 PieceStore가 맡는다. */ export class BearCrushMode implements GameMode { readonly traits:ModeTraits={input:'stroke',tablePieces:false,inputPlaneY:CRUSH.beltY,shakeWhileReady:true,landShake:false,suppressMotion:false,judgeBelow:false,landingHud:false}; private session:BearCrushSession|null=null; private sceneView:CrushScene|null=null; /** 압착 모델을 빌렸는지(장면 생성이 실패해도 exit 가 반납한다) */ private pressLent=false; private pieces:JellyPiece[]=[]; private generation=0; private ready=false; private prepError=false; private ending:number|null=null; private hudKey=''; constructor(private readonly host:ModeHost) {} handles(level:LevelDefinition){return level.mode==='bearCrush';} hint(){return this.host.level().bearCrush?.goal==='all'?'Tap fast! Crush every bear!':'Spare the marked color. Tap to crush every other bear!';} tool(){return null;} scene(){return {view:{...TABLE_VIEW,elevationDeg:38,azimuthDeg:135,azimuthPortrait:125,fitPortrait:1.4},sceneK:0};} materialOptions(level:LevelDefinition,id:number){return {press:true,bearColor:BEAR_COLORS[level.bearCrush!.colors[id]].rgb};} /** 판의 압착 곰 재질 셰이더와 압착 그림자를 쥔다(색은 유니폼이라 조각마다 같은 프로그램) */ keepShaders(level:LevelDefinition,tier:QualityTierLevel):Promise{return this.host.shaders.keepJelly(createJellyMaterial(level.jellyType,tier,this.materialOptions(level,0)),tier,level.id,false);} /** 결과 카드 동안 시작한 다음 판 준비(enter 가 같은 판이면 넘겨받고, 아니면 버린다) */ private pending:PressJellyPrep|null=null; /** * 다음 판 테이블 준비를 대신한다. 결과 카드가 뜬 다음 프레임부터 압착 모델과 곰 젤리(메시·SDF·재질·컴파일)를 프레임을 나눠 만든다. * enter 는 끝난 준비를 바로 붙이고, 덜 끝났으면 이어서 끝낸다 */ readonly prep:ModePrep={replacesTable:true, prepare:(level)=>{this.pending?.cancel();this.pending=this.startPrep(level,true);}, drop:()=>{this.pending?.cancel();this.pending=null;}, id:()=>this.pending?.levelId}; enter(level:LevelDefinition) { const generation=++this.generation; this.ready=false;this.prepError=false;this.ending=null;this.hudKey=''; this.host.ui.setHintVisible(true); const shape=getJellyShape(level.jellyType); if(!level.bearCrush) throw new Error('곰 젤리 압착 정의 없음'); this.session=new BearCrushSession(shape.outline,level.bearCrush); const press=this.host.pressModels.borrow(this.host.pressEquipment());this.pressLent=true; this.sceneView=new CrushScene({speed:level.bearCrush.speed,down:BEAR_CRUSH.down,hold:BEAR_CRUSH.hold,up:BEAR_CRUSH.up},press.model);this.host.content.add(this.sceneView.root); if(press.created)this.precompilePress(this.sceneView); this.sceneView.update(this.session.snapshot(),0,this.host.reducedMotion()); // 결과 카드 동안 같은 판을 준비했으면 넘겨받고(덜 끝났으면 이어서), 아니면 버리고 지금 준비한다 const job=this.pending?.levelId===level.id?this.pending:null; if(!job)this.pending?.cancel(); this.pending=null; void this.adopt(level,generation,job??this.startPrep(level,false)); } /** 압착 모델 셰이더를 기다리지 않고 미리 컴파일한다(컨텍스트를 잃은 동안은 하지 않는다. 실패는 기록만) */ private precompilePress(view:CrushScene) { const h=this.host; // 컨텍스트를 잃은 동안이면 다음 빌림에서 다시 미리 컴파일하게 한다 if(h.contextLost()){h.pressModels.invalidateCompile();return;} view.precompile(h.render.renderer,h.render.camera,h.render.scene).catch((e:unknown)=>console.error('압착 모델 미리 컴파일 실패',e)); } /** 준비 작업 하나를 시작한다. fromCard 면 결과 카드가 뜬 프레임을 피해 다음 프레임에 압착 모델부터 만든다 */ private startPrep(level:LevelDefinition,fromCard:boolean):PressJellyPrep { const host=this.host; return new PressJellyPrep(level.id,async stale=>{ if(fromCard){ await host.clock.nextFrame();if(stale())return null; host.pressModels.prepare(host.pressEquipment()); } return this.build(level,stale); },r=>{for(const p of r.pieces)p.dispose();}); } /** 곰 젤리 메시·SDF·재질을 만들고 현재 등급 프로그램을 데운다(장면·PieceStore 에는 붙이지 않는다). 멈추면 만든 것을 치우고 null */ private async build(level:LevelDefinition,stale:()=>boolean):Promise { const host=this.host,shape=getJellyShape(level.jellyType); const data:PieceData[]=Array.from({length:level.bearCrush!.colors.length},(_,id)=>({id,polygon:shape.outline,cutEdges:shape.outline.map(()=>false),offset:{x:0,y:0}})); const made:JellyPiece[]=[]; let assets:Awaited>|null=null; try { assets=await host.prebuildParts(data,shape,stale); if(stale()) return null; if(assets.size!==data.length) throw new Error('압착 젤리 준비 누락'); const r:BearPrep={pieces:made,tier:host.tier()}; for(const d of data) { made.push(new JellyPiece(d,shape,createJellyMaterial(level.jellyType,r.tier,this.materialOptions(level,d.id)),undefined,assets.get(d.id))); assets.delete(d.id); } await this.warm(level,r,stale); if(stale()) return null; const out:BearPrep={pieces:made.splice(0),tier:r.tier}; return out; } finally { for(const p of made)p.dispose(); if(assets)for(const a of assets.values())disposePrepared(a); } } /** 화면에 나타나기 전에 현재 등급 재질 프로그램을 컴파일한다(준비 중이나 넘겨받기 전에 등급이 바뀌었으면 재질을 바꿔 다시) */ private async warm(level:LevelDefinition,r:BearPrep,stale:()=>boolean) { const host=this.host; for(;;) { if(r.tier!==host.tier()){ r.tier=host.tier(); for(const p of r.pieces)p.replaceMaterial(createJellyMaterial(level.jellyType,r.tier,this.materialOptions(level,p.data.id))); } const warm=new THREE.Scene(); for(const p of r.pieces) warm.add(new THREE.Mesh(p.mesh.geometry,p.mesh.material)); await host.render.renderer.compileAsync(warm,host.render.camera,host.render.scene); if(stale()||r.tier===host.tier()) return; } } /** 준비를 넘겨받아(없으면 지금 시작한 준비) 끝나면 장면에 붙인다. 등급이 바뀌었으면 맞춘 뒤 붙인다 */ private async adopt(level:LevelDefinition,generation:number,job:PressJellyPrep) { const host=this.host; const stale=()=>generation!==this.generation; let r:BearPrep|null=null; try { r=await job.adopt(stale); if(!r) return; if(r.tier!==host.tier()){await this.warm(level,r,stale);if(stale())return;} this.pieces.push(...r.pieces);r.pieces=[]; for(const p of this.pieces) host.pieces.add(p); this.updateScene(0);this.ready=true;host.ui.updateHud(); } catch(error) { if(!stale()) {this.prepError=true;host.ui.setPrepError('Unable to prepare jellies','Return Home and try again');} console.error('곰 젤리 압착 준비 실패',error); } finally { if(r)for(const p of r.pieces)p.dispose(); } } exit() { ++this.generation;this.ready=false; for(const p of this.pieces)this.host.pieces.removeIf(p); this.pieces=[];this.sceneView?.dispose();this.sceneView=null;if(this.pressLent){this.host.pressModels.release();this.pressLent=false;}this.session=null;this.ending=null; this.host.ui.hidePanel(); this.host.ui.setHintVisible(true); } canDraw(){return this.ready&&!this.prepError&&!this.host.lifecycle.hidden&&!this.session?.done;} onPlayUi(){} cancelInput(){} menuReturn(){return {reload:true};} sweepPieces(){return [];} camera(_ctx:CameraContext){ // +x 방향으로 흐르는 곰이 먼 우측 상단에서 가까운 좌측 하단으로 접근한다. const h=this.host, size=h.viewSize(); // 화면 밖으로 연장한 벨트 전체 대신 플레이 영역에 그림자 해상도를 집중한다. h.render.sceneMgr.setShadowExtent(8.8); h.render.sceneMgr.fitCamera(size.width,size.height,_ctx.topInset,_ctx.bottomInset,4.8); // 세로 화면에서는 넓은 벨트를 담는 거리가 기본 far(50)를 넘는다. // 카메라부터 월드 원점 및 장면의 가장 먼 모서리까지 여유를 확보한다. const camera=h.render.camera; camera.far=Math.max(camera.far,camera.position.length()+CRUSH.beltHalfWidth+4); camera.updateProjectionMatrix(); return {fitted:true as const}; } stroke():StrokeHandlers { return {primaryOnly:true,start:()=>{ if(!this.canDraw()||!this.host.sceneInputReady())return; if(this.session?.press())this.host.ui.hideTutorial(); },move:()=>{},end:()=>{},cancel:()=>{}}; } beforePieces(dt:number,_base:number,_realDt:number) { const h=this.host; if(!this.ready||!this.session||h.lifecycle.hidden||h.contextLost())return; if(this.ending!==null) { if(h.state.phase!=='animating')return; this.ending-=dt; if(this.ending<=0){this.ending=null;h.finishLevel();} return; } if(h.state.phase!=='ready'||!h.sceneInputReady())return; for(const hit of this.session.advance(dt))this.impact(hit); this.updateScene(dt); const s=this.session.snapshot(), key=`${s.strikes}:${s.jellies.filter(j=>j.outcome!=='pending').length}:${s.mistakes}`; if(key!==this.hudKey){this.hudKey=key;h.ui.updateHud();} if(s.done){h.ui.setHintVisible(false);h.state.to('animating');this.ending=BEAR_CRUSH.endDelay;} } private impact(hit:BearImpact) { const h=this.host; const failed=hit.kind==='missed'||hit.mistake; h.feel.pulse(failed?[8,30,8]:hit.score>=90?[25]:hit.score>0?[15]:[8]); if(hit.kind!=='missed'&&!h.reducedMotion())h.feel.shake.trigger(hit.score>0?0.025:0.009); if(hit.kind==='contact')h.progress.discoverAll([h.level().jellyType]); const label=hit.kind==='missed'?'Missed bear!':hit.kind==='empty'?'Miss':hit.mistake? this.session!.definition.goal==='spare'&&hit.color===this.session!.definition.protectedColor?'Protected bear!':'Too light!': hit.score>=90?'Perfect!':'Crushed!'; h.ui.showText(label,h.plateToScreen({x:0,y:1.4},CRUSH.beltY),0.6); } /** dt: 프레임 게임 시간(압착 모델 update 훅. 준비 직후 첫 배치는 0) */ private updateScene(dt:number) { const s=this.session!.snapshot();this.sceneView?.update(s,dt,this.host.reducedMotion()); for(const j of s.jellies){ const p=this.pieces[j.id];if(!p)continue; p.visualOffset.x=j.x;p.lift=CRUSH.beltY;p.syncTransform(); // 화면 안에서 갑자기 나타나거나 사라지지 않도록 카메라의 프러스텀 컬링에 맡긴다. // 작은 감쇠 출렁임만 더한다. 완전히 원상 복구하지 않는다. if(j.contact)p.setPress(j.contact,1-0.07*Math.exp(-j.impactAge*8)*Math.pow(Math.sin(j.impactAge*28),2)); } } afterGuides(_dt:number){} afterPieces(_dt:number){} beforeRender(){} result():StageResult {return this.session!.getResult();} resultView(r:StageResult){return bearCrushResultView(r,this.session!.snapshot().mistakes,this.session!.definition.goal);} hudInfo(){ const def=this.host.level().bearCrush!; const s=this.session?.snapshot();const n=def.goal==='all'?def.colors.length:def.colors.filter(c=>c!==def.protectedColor).length; const safe=s?.jellies.filter(j=>j.outcome==='safe').length??0; const protectedCount=def.colors.length-n; return {levelId:this.host.level().id,targetLabel:def.goal==='all'?'Goal':'Spared',target:def.goal==='all'?'All bears':`${safe} / ${protectedCount}`,countLabel:'Crushed',used:s?.jellies.filter(j=>j.outcome==='crushed').length??0,max:n,pips:false,extra:{label:'Mistakes',value:String(s?.mistakes??0),alert:(s?.mistakes??0)>0},objective:def.goal==='all'?{goal:'all' as const}:{goal:'spare' as const,name:BEAR_COLORS[def.protectedColor].name,hex:BEAR_COLORS[def.protectedColor].hex}}; } debug():ModeDebug {return {bearCrush:()=>this.session?{...this.session.snapshot(),ready:this.ready,prepError:this.prepError,center:this.host.plateToScreen({x:0,y:0},CRUSH.beltY)}:null};} }